This guide seeks to provide a guide to setting up a Tor hidden service with a moderate-to-high level of safety. It was compiled on, and was accurate as of, 2019-11-19.

While browsing the dark web, as one is to do on occasion, I noticed a trend of poorly configured onion services. They were leaking all sorts of information through their misconfigurations. I started to look for guides on setting up onion services, and noticed that a lot of those were substandard and frequently lacked a date, making it hard to know if you’re setting up something that’s considered good today or if it was considered good 10 years ago. Like any good computer nerd, I saw this opportunity to develop my own competing standard.

XKCD Standards

This guide DOES NOT cover:

  • Secure application development to prevent leakage
  • Operational security to prevent your local machine from being compromised
  • Training on how to STFU


This guide walks through setting up a Tor hidden service for both server administration as well as for hosting your website. It uses the following technologies:

  • Ubuntu 18.04.3 LTS
  • tor - What’s a hidden service without tor? Well, it’s not.
    • Hidden Services Version 3
    • x25519 Key Generation for ClientAuthorization
  • nginx - nginx is a lot less leaky out of the box and I prefer the way the configuration looks.
  • ufw - ufw is a trimmed down firewall that makes setting up rules super easy at the cost of some flexibility.

Throughout the guide, I reference the contents of some of the values you generate during setup. These are:

  • <private_key> is the “private” value of the output
  • <public_key> is the “public” value of the output
  • <server_ipaddr> is the IP address of the server you’re setting this up on.
  • </var/lib/tor/hidden_ssh/hostname> which means to fill in the contents of the hidden_ssh hostname file
  • </var/lib/tor/hidden_http/hostname> which means to fill in the contents of the hidden_http hostname file

The context of commands switches periodically throughout this guide. I’ve tried to make them as consistent as possible, however.

  • user@local:~$ indicates your local user on your local machine
  • root@remote:~$ indicates your root account on the remote machine that you’re putting the hidden service on. This is only used until we setup the user account, and all subsequent access uses the user account.
  • user@remote:~$ indicates your user account on the remote machine that you’re putting the hidden service on

Whenever I enter a text editor session, the following lines represent what goes in the edited file. At the line that begins with user@, this is your indication to save the edited file and exit the editor. If you see [...] immediately after a text editor opening line, this is an indication that there will be plenty of other content in there that you (probably) don’t need to worry about.

Okay enough preface. Let’s begin.

Setting up server administration

As part of our effort to set up a safe hidden service, we’re going to separate the management address from the customer address. This means we’re going to setup an onion address specifically for ssh access, and we’re never going to share that onion address with anyone. We’ll set up a separate “public” onion address that can be shared for the web server later.

Local Tor setup

Before we begin setting up the hidden service server, we need to do some preparation on our local machine. We’ll install tor, download a python script that generates x25519 certificates for us, and prepare ourselves for Hidden Service V3 Client Authorization. In order to use the x25519 generator, we need to install the pynacl package, which we’ll install to our user environment to avoid contaminating the system level packages.

user@local:~$ sudo apt-get install tor
user@local:~$ wget
user@local:~$ sha256sum
user@local:~$ pip3 install --user pynacl
user@local:~$ python3 | tee torclientauth

Now that we’ve installed tor and prepared our clientauth information, let’s configure our local Tor daemon to look for Client Onion Authorization keys in /var/lib/tor/onion_auth. We’ll also prepare that directory and create a mockup version of the private file, even though we don’t know the onion address we’re going to use yet. Finally, we’ll make sure that the debian-tor user owns the ClientOnionAuthDir directory and then restart tor. Optionally, you can ensure that tor is running with ps afxu | grep [t]orrc to find references to torrc in the running process list.

Note: The value of <private_key> is the “Private” value that was created by the previous command.

user@local:~$ sudo nano /etc/tor/torrc
ClientOnionAuthDir /var/lib/tor/onion_auth

user@local:~$ sudo mkdir /var/lib/tor/onion_auth
user@local:~$ sudo nano /var/lib/tor/onion_auth/myonion.auth_private

user@local:~$ sudo chown -R debian-tor:debian-tor /var/lib/tor/onion_auth
user@local:~$ sudo systemctl restart tor
user@local:~$ ps afux | grep [t]orrc

We’ve nearly got our local tor instance completely set up, but we’ll need to come back to fill out the ONIONADDRWEDONTKNOWYET after it’s generated.

Local SSH setup

For the sake of not cross contaminating any information about ourselves, let’s make a brand new ssh key that we’ll only use with this server. I called it onion_key but you can call it whatever you want. Be sure to select a strong password for this key, that way even if it’s stolen, it won’t be useful by itself. Note that if someone has compromised your machine, it is likely they could also be keylogging to detect this password. It’s not a perfect defense.

Once the key is done generating, edit our local ssh config file and setup IdentitiesOnly yes for all hosts we try to ssh to. This means that our local ssh client won’t try to send any of our ssh keys to the server unless they are explicitly called out, either on the command line via -i ~/.ssh/onion_key or in our ssh config via IdentityFile ~/.ssh/onion_key.

This may be annoying at first, because it requires you to specify which identity to use for every ssh connection, but if you care about your opsec then you should get used to it. If your onion is compromised (which hopefully it won’t be), an adversary could watch the sshd to see what public keys are being provided to it. The last thing you want is for the public keys that are linked to your github to be sent to your secret onion administration address, thereby linking the onion administrator to a publicly known key.

user@local:~$ ssh-keygen -t rsa -b 4096 -f ~/.ssh/onion_key
user@local:~$ nano ~/.ssh/config
Host *
    IdentitiesOnly yes

Now it’s time to ssh into the remote server and begin the setup.

user@local:~$ ssh -i ~/.ssh/onion_key root@<server_ipaddr>

Remote initial setup

Upon initial connection, let’s make sure that everything is up to date and then install the packages we’re going to need for our hidden service, namely tor, and nginx. After we’ve installed our packages, add a new user which we’ll use to access the server in the future. Since we’re going to be setting up ssh over tor, all logins will come from, and if you end up wanting to share this server with your ~co-conspirators~ friends then it’s important to have accountability. If everyone is logging in as root and one of you gets compromised, either digitally or federally, you have no way to identify which user has done the bad things on your hidden service.

I like to choose generic usernames, such as user, for systems like this. Cleverness is the enemy of opsec. Make our initial user a sudoer, copy our current authorized_keys into the new user’s authorized_keys and make sure the permissions are all set.

root@remote:~$ apt-get update && apt-get upgrade && apt-get install -y tor nginx
root@remote:~$ adduser user
root@remote:~$ usermod -aG sudo user
root@remote:~$ mkdir ~user/.ssh && cp ~/.ssh/authorized_keys ~user/.ssh/authorized_keys
root@remote:~$ chown -R user:user ~user/.ssh
root@remote:~$ chmod go-rx ~user/

Once the new user has been created, let’s try to login as the new user to the server.

user@local:~$ ssh -i ~/.ssh/onion_key user@<server_ipaddr>

Perfect (I hope). Let’s modify the remote sshd configuration to prevent root from ever logging in directly, as well as ensure that only public keys can be used for authentication.

user@remote:~$ sudo nano /etc/ssh/sshd_config
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no

Since we’re already logged in as the user and we were able to use sudo to edit the sshd config, we know that we don’t need to be able to login remotely as root anymore. Let’s go ahead and restart the sshd to make sure our changes take effect.

user@remote:~$ sudo systemctl restart sshd

Just to be sure of our configuration, let’s test logging into the server as root over ssh.

user@local:~$ ssh -i ~/.ssh/onion_key root@<server_ipaddr>
root@<server_ipaddr>: Permission denied (publickey).

We should receive a Permission denied (publickey). error message. This means our security measures are doing what we wanted them to do.

Remote host Tor setup

Now that we’re logged in as our user and have taken basic ssh security precautions, it’s time to start configuring tor. We can append these lines to the bottom of your etc/tor/torrc.

user@remote:~$ sudo nano /etc/tor/torrc
HiddenServiceDir /var/lib/tor/hidden_ssh/
HiddenServiceVersion 3
HiddenServicePort 22

This tells tor that we’re going to have a new hidden service, which will be internally referred to as hidden_ssh and that we want it to use HiddenServiceVersion 3. Finally, it tells tor to provide the hidden service on port 22, and to bind to This means that whatever is running on will be exposed via the hidden service we’re creating.

Restart tor and you should have a new hidden service in /var/lib/tor/ called hidden_ssh. Grab the hostname, which will be referenced throughout the rest of this guide as </var/lib/tor/hidden_ssh/hostname>.

Note: You really don’t want to lose this onion address. If you do, you likely won’t be able to get back into your server.

user@remote:~$ sudo systemctl restart tor
user@remote:~$ sudo cat /var/lib/tor/hidden_ssh/hostname

Before we get too excited, let’s make sure we aren’t likely to lose this address by putting it in our ssh config on our local machine.

user@local:~$ nano ~/.ssh/config
Host *
    IdentitiesOnly yes

Host myonion
    HostName </var/lib/tor/hidden_ssh/hostname>
    Port 22
    User user
    IdentityFile ~/.ssh/onion_key
    ProxyCommand nc -X 5 -x localhost:9050 %h %p

Wonderful, now that we’ve got that saved, let me tell you what it does. From now on, when you want to access your onion, you can simply type ssh myonion and it will automatically use nc to proxy that connection to your local tor port and automatically use your specified ssh key for your onion. Just make sure tor is running before you ssh, otherwise you’ll get connection errors.

Now that that’s setup, let’s go back to our ClientOnionAuthDir from earlier and put the onion address in our .auth_private file.

user@local:~$ sudo nano /var/lib/tor/onion_auth/myonion.auth_private

Our client is now ready to start doing client authorization against our onion server, but we still need to configure the hidden service to use client authorization.

Note: <public_key> is the value generated at the beginning of this guide via the python command. It should be saved in your local home directory in a file called torclientauth in case you’ve lost it.

user@remote:~$ sudo mkdir /var/lib/tor/hidden_ssh/authorized_clients
user@remote:~$ sudo nano /var/lib/tor/hidden_ssh/authorized_clients/user.auth

user@remote:~$ sudo chown -R debian-tor:debian-tor /var/lib/tor/authorized_clients/
user@remote:~$ sudo systemctl restart tor

We restart the tor daemon just to be sure that it knows about the authorized_clients directory in our hidden_ssh hidden service.

Now let’s confirm that our ssh config works and that client auth is working.

user@local:~$ ssh myonion

Confirming Tor survives reboots

Before we turn off our clearnet tor access, let’s make sure that tor comes up fine on reboot.

user@remote:~$ sudo shutdown -r now

Wait a few minutes for it to come back up and try to ssh to the onion again.

user@local:~$ ssh myonion

Disabling clearnet access to sshd

Assuming you’re not locked out (and if you followed closely along, you shouldn’t be), you can now safely switch OpenSSH to only listen on localhost.

user@remote:~$ sudo nano /etc/ssh/sshd_config

user@remote:~$ sudo systemctl restart sshd

If you were logged in over the ip address in your current terminal, you will have likely lost connection and will need to connect via the onion address from now on. Now you can attempt to ssh to the host again over clearnet and you’ll see that the connection is refused.

user@local:~$ ssh -i ~/.ssh/onion_key user@<server_ipaddr>
ssh: connect to host <server_ipaddr> port 22: Connection refused

Setting up the firewall

We want to block all access to this server unless it comes to our hidden services. We’re only running our hidden service on this machine, and we want to prevent as much leakage as possible. So we’ll start up ufw and start to configure it.

user@remote:~$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup

user@remote:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

If we try to ssh to the ip address from the internet again, we’ll see the behavior has changed from the previous Connection Refused message to a Connection timed out error message. This means that the firewall is blocking the connection before it can even try to find a process listening on port 22.

user@local:~$ ssh -i ~/.ssh/onion_key root@<server_ipaddr>
ssh: connect to host <server_ipaddr> port 22: Connection timed out

This applies for all incoming connections. By default, ufw will deny all incoming connections unless they match a specific rule. And we’re not going to set any rules to allow incoming connections, because all of our connections will come from tor, which appears as to the host.

Advanced firewall rules (Paranoid Mode)

Now that all incoming access is refused, we can assume that any incoming connections will come from the Tor hidden service. That’s great, but let’s also make sure that our server doesn’t unintentionally leak its address via outbound connections by restricting the outbound firewall policy to only the ports necessary for tor to function.

user@remote:~$ sudo ufw allow out 9001
Rule added
Rule added (v6)
user@remote:~$ sudo ufw allow out 9030
Rule added
Rule added (v6)
user@remote:~$ sudo ufw default deny outgoing
Default outgoing policy changed to 'deny'
(be sure to update your rules accordingly)

Now if we try to curl a remote web server, we’ll see that it doesn’t work.

user@remote:~$ curl
curl: (6) Could not resolve host:
user@remote:~$ curl
curl: (7) Failed to connect to port 80: Connection timed out
user@remote:~$ curl
curl: (7) Failed to connect to port 80: Connection timed out
user@remote:~$ dig

; <<>> DiG 9.11.3-1ubuntu1.9-Ubuntu <<>>
;; global options: +cmd
;; connection timed out; no servers could be reached

NOTE: This will prevent many functions that you take for granted, such as apt-get update && apt-get upgrade. Whenever you need to do updates, simply sudo ufw default allow outgoing and perform the actions you need, and then sudo ufw default deny outgoing again. This also does not work if your application needs to make regular outbound connections, via http/dns/smtp/etc. I will cover that option in another post.

Preventing ssh login from revealing public IP

With all this work that we’re doing to setup hidden services, it would really suck if we ssh’d into our machine and it just told anyone who could see our screen what the real IP was. There are a few ways to prevent this information from popping up in the ubuntu motd.

This is a minor detail and might not be that important, but it reduces the chances of someone seeing the actual IP address of the server inadvertently. Of course if someone has access to our machine once we’re logged in, they can simply run ip addr and figure it out that way, this is just meant to prevent accidental leakage.

Modifying landscape-sysinfo

The system information that is run to generate the MOTD comes from a process called landscape-sysinfo, and is called from /etc/update-motd.d/50-landscape-sysinfo. We can modify this file to exclude the network information when landscape-sysinfo is run.

user@remote:~$ sudo nano /etc/update-motd.d/50-landscape-sysinfo
	/usr/bin/landscape-sysinfo --exclude-sysinfo-plugins=Network

Go ahead and save that file. While we’re editing the MOTD, let’s also remove the ubuntu help text, as we’ll probably never want to click on it and it just takes up a bunch of space.

user@remote:~$ sudo rm /etc/update-motd.d/10-help-text

Now go ahead and log out, then log back in, and we’ll see that the network information and help text is no longer included.

user@local:~$ ssh myonion
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-66-generic x86_64)

  System information as of Tue Nov 19 06:05:11 UTC 2019

  System load: 0.0               Memory usage: 16%   Processes:       86
  Usage of /:  4.9% of 24.06GB   Swap usage:   0%    Users logged in: 0

34 packages can be updated.
19 updates are security updates.

Last login: Tue Nov 19 06:01:14 2019 from


Alternatively, we can use the hushlogin method to silence the login entirely. Simply create a file named .hushlogin in our home directory on the remote server in order to hide the MOTD entirely. If you don’t care about seeing system load, disk usage, etc, on login then this works great.

user@remote:~$ touch ~/.hushlogin
user@remote:~$ exit
user@local:~$ ssh myonion

Setting up our hidden service web server

Now that we’ve got administrative access to our machine locked down, let’s set up our web server. Before we start changing anything, let’s just take a look at the default headers that nginx is returning.

user@remote:~$ curl -I localhost
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 19 Nov 2019 06:16:01 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Mon, 18 Nov 2019 22:54:28 GMT
Connection: keep-alive
ETag: "5dd32124-264"
Accept-Ranges: bytes

Disabling nginx server_tokens

As you may see, our web server is currently serving up it’s exact version and that we’re running Ubuntu, via the Server header. Turning this off is easy, though!

user@remote:~$ sudo nano /etc/nginx/nginx.conf
	server_tokens off;

user@remote:~$ sudo systemctl restart nginx
user@remote:~$ curl -I localhost
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 19 Nov 2019 06:22:25 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Mon, 18 Nov 2019 22:54:28 GMT
Connection: keep-alive
ETag: "5dd32124-264"
Accept-Ranges: bytes

You can see now that the Server header no longer reveals the host operating system OR the version number. We could take it one step further and provide a custom Server header value as a means of misdirection by using the ngx_headers_more module, however that is beyond the scope of this walkthrough.

You’ll also see that the server tokens do not appear in the default nginx error pages, either:

user@remote:~$ curl localhost/notfound
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>

Setting up global nginx http config

This won’t matter as much as long as you’re not also serving up content on the broader internet, but by configuring nginx with size restrictions for its various buffers, we can help ward off DoS attacks that try to exhaust the server resources by sending very large requests. Just put these lines in the http block of the nginx.conf file.

user@remote:~$ sudo nano /etc/nginx/nginx.conf
	client_body_buffer_size 1k;
	client_header_buffer_size 1k;
	client_max_body_size 1k;
	large_client_header_buffers 2 1k;

While you’re in there, let’s also set some universal HTTP headers. These will make it harder to accidentally do the wrong things on your hidden service (and on any website, really). More information can be found here:

	add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self'";

	add_header X-Frame-Options deny;
	add_header X-Content-Type-Options nosniff;
	add_header X-XSS-Protection "1; mode=block";

Setting up the nginx default config

We might be tempted, and some guides might even encourage us, to use the nginx default config for our hidden service. This is bad and we should not do it. Even though our firewall rules deny all incoming packets, that doesn’t give us an excuse to get lazy with our configurations. Let’s delete the default that nginx ships with and create our own very basic default.

user@remote:~$ sudo rm /etc/nginx/sites-available/default
user@remote:~$ sudo mkdir /var/www/default && sudo touch /var/www/default/index.html
user@remote:~$ sudo nano /etc/nginx/sites-available/default
server {
	listen default_server;
	root /var/www/default;
	index index.html;
	server_name _;

Now let’s test the nginx config to ensure everything is valid.

user@remote:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Now our default config is as minimal as possible, but it will safely (in that it won’t leak our hidden service content) handle any errant requests that don’t hit our onion address but still somehow make it to us.

Setting up a Tor Hidden Service for HTTP

Now it’s finally time to start setting up the Hidden Service for our website. I know, it’s been forever. You might have even dozed off along the way, or maybe forgot what our goal was in the first place. But I assure you that we’re almost there. The finish line is near.

First, we need to add another HiddenServiceDir to our tor configuration.

user@remote:~$ sudo nano /etc/tor/torrc
HiddenServiceDir /var/lib/tor/hidden_http/
HiddenServiceVersion 3
HiddenServicePort 80

Now that this is done, we need to restart tor. When we do this, our ssh connection will disconnect because the tor service restarts and new circuits are generated. So make sure you didn’t have a typo in your most recent changes.

user@remote:~$ sudo systemctl restart tor
user@local:~$ ssh myonion

I hope you’re still with me. These are high stakes games we’re playing.

Setting up our site config

It’s FINALLY time to setup our onion service. For this example, we’re going to stick to a simple HTML application without a dynamic backend, however I will cover safely setting up various backends in other guides at a later date.

First, let’s prepare some content for our new super secret site. For this example, I’m just going to echo some content into the index file. You could scp content from your local machine instead, if you’d like.

user@remote:~$ sudo mkdir /var/www/onion
user@remote:~$ sudo echo "Super Secret Hidden Service" >> /var/www/onion/index.html

And now we tell nginx about our new site, which we’ll call onion. Once we’ve added the server block for onion, we need to create a symlink between sites-available and sites-enabled so that there’s only one version of the file - the authoritative version. This method is superior to copying the file, as we can’t accidentally forget to copy the new config before restarting the server.

user@remote:~$ sudo nano /etc/nginx/sites-available/onion
server {
	root /var/www/onion;
	index index.html;
	server_name </var/lib/tor/hidden_http/hostname>;

user@remote:~$ sudo ln -s /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion

Let’s test the nginx config again to ensure everything is still valid.

user@remote:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If the test isn’t successful and you got an error message like this:

user@remote:~$ sudo nginx -t
nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 64
nginx: configuration file /etc/nginx/nginx.conf test failed

Then you need to edit /etc/nginx/nginx.conf and uncomment the server_names_hash_bucket_size line and set the value to 128.

user@remote:~$ sudo nano /etc/nginx/nginx.conf
	server_names_hash_bucket_size: 128;

Now let’s try that nginx test again.

user@remote:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If everything is successful, it’s time to test our new configuration.

user@remote:~$ sudo systemctl restart nginx
user@remote:~$ curl -v localhost
curl -v localhost
* Rebuilt URL to: localhost/
*   Trying
* Connected to localhost ( port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.58.0
> Accept: */*
< HTTP/1.1 200 OK
< Server: nginx
< Date: Tue, 19 Nov 2019 07:23:42 GMT
< Content-Type: text/html
< Content-Length: 0
< Last-Modified: Tue, 19 Nov 2019 07:16:21 GMT
< Connection: keep-alive
< ETag: "5dd396c5-0"
< X-Frame-Options: SAMEORIGIN
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Accept-Ranges: bytes
* Connection #0 to host localhost left intact

Notice that no content was returned from the call to localhost. That tells us that it’s reading from /var/www/default/index.html which we made an empty file earlier. Any requests to the nginx server that don’t have the Host header set to our onion address will get directed here, which won’t leak any information about the hidden service.

We can also test our onion configuration from the server:

user@remote:~$ torify curl -v </var/lib/tor/hidden_http/hostname>
Super Secret Hidden Service

Back on our local machine, let’s test our connection to the tor http service, just to be sure.

user@local:~$ torify curl </var/lib/tor/hidden_http/hostname>
Super Secret Hidden Service

Note: Great success.


At this point, you should be able to give out your http onion address without fear that someone will find the hosting server. You can also rest assured that your ssh access logs won’t be flooded with bad attempts to connect, because you’re the only one who knows the address, and it requires Tor ClientAuthorization in order to even make the connection.

I would like to write additional guides on making sure that your applications aren’t leaking your IP address, setting up your outbound email from your application to go through tor, setting up tor relays, setting up tor bridges, setting up a tor IRC network, etc. Prior to working on those, I plan to create a video companion for this guide.

I hope you’ve learned something along the way, and if you have any questions, concerns, or recommendations, please @ me or shoot me a DM on any of my social platforms.