Unix Domain Socket (UDS) can be a fantastic tool that we can use. In this post I explain what it is, speak about advantages and disadvantages as well. Keep in mind, that every aspect I mention, is related for Linux. So it may work differently on MacOS or Windows, but, at least from my view, server operating system is a Linux distribution.

Introduction

Let’s start with basic terminologies, that I use during this post. I am not going details them, rather explain its functionality with some easy and understandable analogy.

What is a socket?

Simplest way to understand, think about a electric plug on the wall: you plug-in your device, then electricity flow through the devices. Just like a socket on the wall, socket in a computer workds similiarly.

Via socket, we are able to send and receive data over various protocol, like TCP, UDP or Unix Domain Socket. Sockets has a common API that implements system calls like socket(), bind(), connect() or accept(). Just like everything in Linux, it is also a file. Which technically mean, each socket has a file descriptor.

Inter Process Communication

The Inter Process Communication (or IPC) is self-explanatory. It is usual that we can have micro services or servers and clients that are connected with each other. But within the same operating system, we also want applications to communicate.

IPC has multiple solution, in this post, this is only mentioned and explained via Unix Domain Socket.

Advantages of using Unix Domain Socket

Unix Domain Socket (or UDS) is a tool for IPC. If a server and client communicate via UDS, it is happens inside the kernel. Which means that cannot be any middle man action, like we can have if we talk over internet. This gives an extra security to UDS, but is also give a limitation: UDS connection only possible within the same system.

Since UDS communication happening via kernel memory, no middle man, which leads to the conclusion: we do not have to care with TLS. Which means that no TLS handshake required during connection, no encryption is required. This releases some computing power and reduce latency.

UDS communication is done via a file based socket, which means that no IP address or port number is required. Because this is “just a file”, we can restrict access to socket using chown or setfacl commands. We can also use systemd socket unit file, you see example for it later.

What is Unix Domain Socket?

UDS are filed-based sockets and implemented via AF_UNIX or AF_LOCAL. Process create a file (e.g.: /run/user/1000/server.sock) and bind on that file. Clients can connect to that file and send/receive data over it. UDS can be used on bidirectional way.

UDS implements a namespace, but they don’t have persistend data like regular files. In the following, I show an output for stat command, then analyze the output.

$ stat ~/test.txt
  File: /home/ati/test.txt
  Size: 10              Blocks: 8          IO Block: 4096   regular file
Device: 0,43    Inode: 455137      Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/     ati)   Gid: ( 1000/     ati)
Context: unconfined_u:object_r:user_home_t:s0
Access: 2025-05-01 20:25:03.412865542 +0200
Modify: 2025-05-01 20:25:00.472405623 +0200
Change: 2025-05-01 20:25:00.476437061 +0200
 Birth: 2025-05-01 20:25:00.472405623 +0200
$ stat /run/user/1000/podman/podman.sock
  File: /run/user/1000/podman/podman.sock
  Size: 0               Blocks: 0          IO Block: 4096   socket
Device: 0,78    Inode: 3509        Links: 1
Access: (0660/srw-rw----)  Uid: ( 1000/     ati)   Gid: ( 1000/     ati)
Context: unconfined_u:object_r:user_tmp_t:s0
Access: 2025-05-24 21:26:37.616319228 +0200
Modify: 2025-05-24 21:26:10.917680796 +0200
Change: 2025-05-24 21:26:10.917680796 +0200
 Birth: 2025-05-24 21:26:10.916943941 +0200

We can see that UDS is indeed a file, it has inode, links, and so on. But we can see that its size and number of blocks are zero. This is because behind a UDS there is no real data block. UDS files are special file types with no persistent data the actual data transfer happens entirely within kernel space.

The file type also indicates it is a socket and not regular file.

But from a high level view, it similar like a network socket: we can use SOCK_STREAM (TCP) or SOCK_DGRAM (UDP) over it. We can also use standards on application layer like HTTP. Unix domain sockets also support SOCK_SEQPACKET, which offers message boundaries with guaranteed ordering and delivery.

Linux supports abstract sockets (@mysocket) which don’t create actual files on disk.

Prove it’s a real socket communcation and not regular file

For this experiment, I create two Go process. Code of server:

// simple_server.go
package main

import (
    "net"
    "os"
)

func main() {
    os.Remove("/tmp/echo.sock")
    l, _ := net.Listen("unix", "/tmp/echo.sock")
    for {
        conn, _ := l.Accept()
        go func(c net.Conn) {
            buf := make([]byte, 128)
            n, _ := c.Read(buf)
            c.Write([]byte("Echo: " + string(buf[:n])))
            c.Close()
        }(conn)
    }
}

And I also create a client:

// uds_client.go
package main

import (
    "fmt"
    "net"
)

func main() {
    conn, _ := net.Dial("unix", "/tmp/echo.sock")
    conn.Write([]byte("ping"))
    buf := make([]byte, 128)
    n, _ := conn.Read(buf)
    fmt.Println(string(buf[:n]))
    conn.Close()
}

After build, I run both using strace utility. This utility make record about system calls. We can check if any open call happening. The trace output is large, I am not going copy the whole here, but I would like to highlight some interesting lines.

$ grep -E 'AF_UNIX|listen\(|bind\(|open\(' server.trace
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
bind(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, 17) = 0
listen(3, 4096)                         = 0
getsockname(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, [112 => 17]) = 0
accept4(3, {sa_family=AF_UNIX}, [112 => 2], SOCK_CLOEXEC|SOCK_NONBLOCK) = 4
getsockname(4, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, [112 => 17]) = 0

$ grep -E 'AF_UNIX|listen\(|bind\(|open\(' client.trace
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
connect(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, 17) = 0
getsockname(3, {sa_family=AF_UNIX}, [112 => 2]) = 0
getpeername(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, [112 => 17]) = 0

We can see, indeed no open syscall happened. If you have already seen any code for network socket, you could have seen that similar calls are performed transmission: socket(), connect() and accept().

If you ever want to listen all UDS, you can do ss -x -a | grep LISTEN. This filters for the listening sockets.

Communication flow over UDS

Communication between server and client is represented on the following diagram.

sequenceDiagram participant Server participant Kernel participant Client Server->>Kernel: socket() Server->>Kernel: bind("/tmp/echo.sock") Server->>Kernel: listen() Note right of Kernel: inode created at /tmp/echo.sock Client->>Kernel: socket() Client->>Kernel: connect("/tmp/echo.sock") Kernel->>Kernel: lookup inode and verify listening socket Kernel->>Server: queue connection for accept() Server->>Kernel: accept() Kernel->>Server: return new socket connection Client->>Kernel: write() Kernel->>Server: data is delivered Server->>Kernel: read() Server->>Kernel: write() Kernel->>Client: data is delivered Client->>Kernel: read()

Example for UDS using

Several applications uses this interface, like Docker. Via Docker socket you can get same information than using Docker CLI. This can be handy in programming, because it is much easier to parse a JSON comparing with a CLI output.

Next sample command list the currently running container’s id.

$ curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json \
    | jq '.[].Id'
"2538d78c2034cb3b144b66fc552bcf7cb84436f2b3534cefbbc62fd8e16cea1e"
"2355b6beb03fe8d06b1be9489f65a7f96f2f2583ec8f748e373e521cdc90483c"
"d068b04712964c23d89a0593ea5eb9e2b0d8f64a0c8097ca55230544488d514c"
"f098a6c04382393bebb729ced2183c2b69463ed533d2492ba293c47e035056e8"
"32c52cebe43fc538a99cd57f92a253bc452dbe65a75848c0466b9c873221c71c"
"5698cc832a361f7ec596cca9a366d1d34aa699a3c647cf8993a50a14b7179fe0"

Build HTTP on UDS

Like in case of Docker in the previous example, it is possible to build an HTTP server on UDS. And why would not do it? HTTP is a very stable standard for communication, having very rich tooling. And building a simple REST API using standard Go library, is simpler than you would thought.

package main

import (
    "net"
    "net/http"
    "os"
)

func main() {
    os.Remove("/tmp/hello.sock")
    l, _ := net.Listen("unix", "/tmp/hello.sock")

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello over UDS!"))
    })

    http.Serve(l, nil)
}

If you reading this code, you can see that we called the unlink call via os.Remove() function. The kernel don’t automatically override it. Run the server above, then execute curl --unix-socket /tmp/hello.sock http://localhost/ code as test.

If you want to add middleware, routers, handlers, and so on, it is just up to you. It does not matter that it is UDS, your application can work as a regular network based REST API. Except a few thing. For example, in this case no source IP address, because, due to limitation of UDS, connection can just coming via local system.

Authenticate on UDS

If you want to use authentication, like OIDC or OAuth2, no problem, you can still do that. But with UDS you can also filter that which user or group id is authorized to use it. We can limit who can have access, on two ways:

  1. Authorization of socket file: Only those can write the socket who has write access for the file. At the end of the day, UDS is also a file. You can use chmod or setfacl to modify the access on the socket.
  2. SO_PEERCRED: This return with a struct that hold information from the user who issued the request.

For a moment, forget about REST API. I show this function on a much simpler code.

// peerceed_server.go
package main

import (
    "fmt"
    "net"
    "os"

    "golang.org/x/sys/unix"
)

func main() {
    sockPath := "/tmp/peercred_example.sock"
    os.Remove(sockPath)

    listener, err := net.Listen("unix", sockPath)
    if err != nil {
        panic(err)
    }
    defer listener.Close()
    fmt.Println("Listening on", sockPath)

    for {
        // Infinite wait for new connections
        conn, err := listener.Accept()
        if err != nil {
            continue
        }

        unixConn, ok := conn.(*net.UnixConn)
        if !ok {
            fmt.Println("Not a UnixConn")
            conn.Close()
            continue
        }

        file, err := unixConn.File()
        if err != nil {
            conn.Close()
            continue
        }
        defer file.Close()

        // Use x/sys/unix to fetch SO_PEERCRED
        cred, err := unix.GetsockoptUcred(int(file.Fd()), unix.SOL_SOCKET, unix.SO_PEERCRED)
        if err != nil {
            fmt.Println("GetsockoptUcred error:", err)
            conn.Close()
            continue
        }

        fmt.Printf("Client connected: PID=%d UID=%d GID=%d\n", cred.Pid, cred.Uid, cred.Gid)

        conn.Write([]byte("Welcome, verified client!\n"))
        conn.Close()
    }
}

Following client is used during testing:

// peercerd_client.go
package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    conn, err := net.Dial("unix", "/tmp/peercred_example.sock")
    if err != nil {
        fmt.Println("Dial error:", err)
        os.Exit(1)
    }
    defer conn.Close()

    buf := make([]byte, 256)
    n, _ := conn.Read(buf)
    fmt.Print(string(buf[:n]))
}

Server process produces output like this:

$ ./peercred_server
Listening on /tmp/peercred_example.sock
Client connected: PID=119446 UID=1000 GID=1000
Client connected: PID=119455 UID=1000 GID=1000
Client connected: PID=119464 UID=1000 GID=1000

When and why is PEERCRED useful?

If you say that you are able to protect the socket file is a fine argument. But there can be other, non-functional requirements, that we have to fulfill like: logging. If we work for a government, bank or any other institute, then logging is always is a question.

And this brought up the next question: how can I authenticate using SO_PEERCRED with a REST API?

Authenticate with SO_PEERCRED in a REST API

From application view, the most logical decision would be a to write a middleware. But from system view, that is not a good way because, we should hijack the file descriptor and working with that. Might be a good solution, I have found that very ugly.

Instead, I create a new listener on top of UnixListener. This new listener is fulfill all required functions for the interface, so we can simply use it in http.Serve method.

// A Listener is a generic network listener for stream-oriented protocols.
//
// Multiple goroutines may invoke methods on a Listener simultaneously.
type Listener interface {
    // Accept waits for and returns the next connection to the listener.
    Accept() (Conn, error)

    // Close closes the listener.
    // Any blocked Accept operations will be unblocked and return errors.
    Close() error

    // Addr returns the listener's network address.
    Addr() Addr
}

The code of the new listener can be seen below. It basically prevent to accept request from any other user than the owner of the process.

// authListener wraps a UnixListener and filters based on UID
type authListener struct {
    *net.UnixListener
}

func (l *authListener) Accept() (net.Conn, error) {
    conn, err := l.UnixListener.AcceptUnix()
    if err != nil {
        return nil, err
    }

    // Extract file descriptor
    file, err := conn.File()
    if err != nil {
        conn.Close()
        return nil, err
    }
    defer file.Close()

    // Get peer credentials (SO_PEERCRED)
    cred, err := unix.GetsockoptUcred(int(file.Fd()), unix.SOL_SOCKET, unix.SO_PEERCRED)
    if err != nil {
        conn.Close()
        return nil, err
    }

    // Accept only connections from the same user
    allowedUID := uint32(os.Getuid())
    if cred.Uid != allowedUID {
        log.Printf(
            "❌ Rejected connection: UID %d (only UID %d allowed)",
            cred.Uid,
            allowedUID,
        )
        conn.Close()
        return nil, tempError{fmt.Errorf("unauthorized UID %d", cred.Uid)}
    }

    log.Printf("✅ Accepted connection from UID=%d PID=%d", cred.Uid, cred.Pid)
    return conn, nil
}

// tempError makes rejected connections non-fatal for http.Serve
type tempError struct {
    err error
}

func (t tempError) Error() string   { return t.err.Error() }
func (t tempError) Timeout() bool   { return false }
func (t tempError) Temporary() bool { return true }

The server part is similar than before, only difference that we use this listener.

package main

import (
    "fmt"
    "log"
    "net"
    "net/http"
    "os"

    "golang.org/x/sys/unix"
)

const socketPath = "/tmp/auth.sock"

func main() {
    os.Remove(socketPath)

    // Start listening on a UDS path
    l, err := net.Listen("unix", socketPath)
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }
    defer l.Close()

    // Set permissions if needed
    if err := os.Chmod(socketPath, 0660); err != nil {
        log.Fatalf("Failed to set socket permissions: %v", err)
    }

    fmt.Println("Listening on", socketPath)

    // Wrap listener to intercept connections
    err = http.Serve(&authListener{l.(*net.UnixListener)}, http.HandlerFunc(handler))
    if err != nil {
        log.Fatalf("HTTP server error: %v", err)
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("✅ You are authorized!\n"))
}

More advanced solutions may exists, this is just a simple introduction. A sample output from the server.

$ ./http_auth_cred
Listening on /tmp/auth.sock
2025/05/24 23:17:56 ❌ Rejected connection: UID 0 (only UID 1000 allowed)
2025/05/24 23:17:56 http: Accept error: unauthorized UID 0; retrying in 5ms
2025/05/24 23:17:59 ❌ Rejected connection: UID 0 (only UID 1000 allowed)
2025/05/24 23:17:59 http: Accept error: unauthorized UID 0; retrying in 10ms
2025/05/24 23:18:03 ✅ Accepted connection from UID=1000 PID=121373

Do you need TLS?

If we implement any service, it is a default in 2025 that we must use trusted certificate with out deployment. Why? Because that traffic goes over internet and at any point a interceptor can act and watch out traffic.

But for now, we know that UDS does not goes over neither internet nor any network. It only available from the machine locally and communcation is done via kernel memory.

Concolusion is, that TLS it not needed. This does not mean the communication is encrypted but since it never leaves the local system and access can be controlled via filesystem permissions and peer credentials, TLS is usually unnecessary.

Of course, a good logging and sometimes UID/GID based authentication can be useful.

Final words

UDS is a simple and secured way for any IPC on Linux. It still secure without handling any TLS configuration, no certificates are required. It also has a very nice integration with systemd.

If I make any dev tool to myself or local API, I use UDS without question. I encourage anybody to do the same.

Bonus (Optional): Using socket with systemd

What problem do we solve, if we used systemd? Let’s imagine a situation: you deploy an application where the application owner is not a priviledged user and wants to allow other user’s to having access for the socket. We can solve this problem by using systemd socket. This allows the socket to be created with proper ownership and permissions before your app starts.

In a systemd socket file, we can define details of it like in the following example.

[Unit]
Description=Socket for UDS-authenticated HTTP server

[Socket]
ListenStream=%t/demo.sock
SocketMode=0660
SocketUser=ati
SocketGroup=ati

[Install]
WantedBy=sockets.target

If this unit is started, it creates a new socket (unlink if already exists). In this case, we have to modify our application, becuse application has to just use a pre-defined file descriptor.

Previous example has been modified. You can see that in the main, we don’t bind or listen, we just use existing file descriptior that we get from systemd via LISTEN_FDS and LISTEN_PID environment variables. This examples assumnes that only one socket is passed to the application, thus LISTEN_FDS is ignored and assumed that its value is 3.

The first file descriptor may be found at file descriptor number 3 (i.e. SD_LISTEN_FDS_START), the remaining descriptors follow at 4, 5, 6, …, if any.

package main

import (
    "fmt"
    "log"
    "net"
    "net/http"
    "os"
    "strconv"

    "golang.org/x/sys/unix"
)

func main() {
    listenPidStr := os.Getenv("LISTEN_PID")

    if listenPidStr == "" {
        log.Fatal("LISTEN_FDS or LISTEN_PID not set. Are you using systemd socket activation?")
    }

    // Confirm PID matches
    pid := os.Getpid()
    expectedPid, _ := strconv.Atoi(listenPidStr)
    if pid != expectedPid {
        log.Fatalf("LISTEN_PID %d does not match current PID %d", expectedPid, pid)
    }

    // LISTEN_FDS tells us how many FDs were passed; first is always FD 3
    fd := 3
    file := os.NewFile(uintptr(fd), "systemd-socket")
    listener, err := net.FileListener(file)
    if err != nil {
        log.Fatalf("Failed to use systemd socket: %v", err)
    }
    fmt.Println("Using socket passed by systemd")

    // Proceed with your wrapped listener
    err = http.Serve(&authListener{listener.(*net.UnixListener)}, http.HandlerFunc(handler))
    if err != nil {
        log.Fatalf("HTTP server error: %v", err)
    }
}

Then, create a unit file for this go process. Name match with the socket unit file, difference that its exteions is service.

[Unit]
Description=Go HTTP Server with UDS + UID Auth
Requires=demo.socket
After=network.target

[Service]
ExecStart=%h/tmp/uds_go/systemd_server
NonBlocking=true
StandardOutput=journal
StandardError=journal
Restart=always

After a systemctl --user daemon-reload command, we can start the socket then execute a test curl command.

$ systemctl --user start demo.socket

$ systemctl --user status demo.docker
Unit demo.docker.service could not be found.

$ systemctl --user status demo.socket
● demo.socket - Socket for UDS-authenticated HTTP server
     Loaded: loaded (/home/ati/.config/systemd/user/demo.socket; disabled; preset: disabled)
     Active: active (listening) since Sun 2025-05-25 00:00:36 CEST; 8s ago
 Invocation: d36ab5e4641d49bcafbc43a261bca513
   Triggers: ● demo.service
     Listen: /run/user/1000/demo.sock (Stream)
      Tasks: 0 (limit: 18919)
     Memory: 0B (peak: 768K)
        CPU: 2ms
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/demo.socket

máj 25 00:00:36 atipc systemd[1830]: Starting demo.socket - Socket for UDS-authenticated HTTP server...
máj 25 00:00:36 atipc systemd[1830]: Listening on demo.socket - Socket for UDS-authenticated HTTP server.

$ systemctl --user status demo.service
○ demo.service - Go HTTP Server with UDS + UID Auth
     Loaded: loaded (/home/ati/.config/systemd/user/demo.service; static)
    Drop-In: /usr/lib/systemd/user/service.d
             └─10-timeout-abort.conf
     Active: inactive (dead)
TriggeredBy: ● demo.socket

After a curl command, we can see that systemd starts the service. This is useful for more reasons:

  • App only runs when needed
  • Permissions and socket ownersips handled by systemd
  • Automatically restart on failure
  • Multiple socket support
  • Works well with systemd
$ curl --unix-socket /run/user/1000/demo.sock  http://localhost/
✅ You are authorized!

$ systemctl --user status demo.service
● demo.service - Go HTTP Server with UDS + UID Auth
     Loaded: loaded (/home/ati/.config/systemd/user/demo.service; static)
    Drop-In: /usr/lib/systemd/user/service.d
             └─10-timeout-abort.conf
     Active: active (running) since Sun 2025-05-25 00:01:13 CEST; 1s ago
 Invocation: 2cf86f6a356844e79cc4207c18ef06b4
TriggeredBy: ● demo.socket
   Main PID: 130394 (systemd_server)
      Tasks: 6 (limit: 18919)
     Memory: 1.3M (peak: 2.5M)
        CPU: 5ms
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/demo.service
             └─130394 /home/ati/tmp/uds_go/systemd_server

máj 25 00:01:13 atipc systemd[1830]: Started demo.service - Go HTTP Server with UDS + UID Auth.
máj 25 00:01:13 atipc systemd_server[130394]: Using socket passed by systemd
máj 25 00:01:13 atipc systemd_server[130394]: 2025/05/25 00:01:13 ✅ Accepted connection from UID=1000 PID=130393