OSI has seven layers: physical, data link, network, transport, session, presentation, application. TCP/IP, the model the internet runs on, has four: link, internet, transport, application. IP is layer 3, TCP and UDP layer 4, HTTP layer 7, and a load balancer works at 4 or 7.
TLS sits on top of TCP and under HTTP (OSI's session or presentation layer). HTTP itself does not change; it travels encrypted.
Each layer wraps the one above: HTTP inside TLS records, inside TCP segments, inside IP packets, inside Ethernet frames.
Addresses: MAC addresses on layer 2 (one network segment), IP addresses on layer 3 (end to end), ports on layer 4 (which process).
Load balancers: an L4 one forwards TCP connections by IP and port; an L7 one parses HTTP and routes by host, path or header.
GoMiddle
Networking & Security for Backend
TCP and the OSI model, DNS, TLS, HTTP/3 and QUIC, load balancers and proxies, WebSockets, CORS, password hashing, OAuth 2.0 and JWT, SSRF, mTLS
Questions closed
0 / 19
- 0Review
Questions
The browser resolves example.com through DNS, opens a TCP connection to port 443, runs a TLS handshake and sends GET /orders over it. A load balancer or proxy usually terminates TLS and forwards the request to a backend, and the response comes back over the same connection.
DNS: the browser and OS caches first, then a recursive resolver that asks the root, the .com servers and example.com's own name servers; the answer is cached for its TTL.
TCP: SYN, SYN-ACK, ACK: one round trip before any data.
TLS 1.3: one more round trip: a key exchange, the certificate checked against trusted CAs and the hostname, and ALPN choosing h2 or http/1.1.
HTTP: the request with Host, cookies and headers goes to the balancer, then a reverse proxy or ingress, then the Go handler.
The next request reuses the kept-alive connection and skips DNS, TCP and TLS.
HTTPS is HTTP over TLS: the handshake agrees on a cipher and verifies the server's certificate, then both sides encrypt with a session key, so traffic is private and tamper-evident.
TLS and SSL: TLS is the successor of SSL; SSL is deprecated, and TLS 1.2 or 1.3 is what runs today.
TCP vs UDP: TCP gives ordered, reliable delivery with retransmission; UDP gives neither, so pick it when late data is worthless or the application does its own recovery: DNS, video calls, games and QUIC (HTTP/3).
In Go: http.ListenAndServeTLS(addr, certFile, keyFile, handler) serves HTTPS, autocert from golang.org/x/crypto obtains Let's Encrypt certificates, and tls.Config{MinVersion: tls.VersionTLS12} on an http.Server rejects old protocol versions.
A TLS-terminating proxy or load balancer usually sits in front and talks plain HTTP to the service over the private network.
CORS is how a server tells the browser which other origins may read its responses, and the browser is what enforces it. So it only limits JavaScript from other sites inside a browser: curl, a script or another backend ignores CORS and gets the response anyway.
Origin is scheme, host and port, so app.example.com calling api.example.com is cross-origin and needs Access-Control-Allow-Origin in the answer.
Simple requests go straight out: GET, HEAD or POST with safelisted headers and a form or text/plain content type. The handler runs; only reading the response is blocked.
Preflight: any other method, Content-Type: application/json or an Authorization header is first announced with an OPTIONS; Access-Control-Max-Age caches the answer.
Cookies go cross-origin only with credentials: "include", and then the server must name the exact origin and send Access-Control-Allow-Credentials: true. Reflecting any Origin there opens the API to every site.
Not access control: authenticate every request. A simple cross-site POST still runs your handler: that is CSRF, stopped by SameSite cookies or http.CrossOriginProtection.
The client sends an ordinary GET with Upgrade: websocket and Connection: Upgrade; the server answers 101 Switching Protocols, and from then on the same TCP connection carries WebSocket frames both ways. Every proxy on the path must pass the upgrade on and let the connection live for hours.
Handshake: the client sends a random Sec-WebSocket-Key, and the server proves it speaks WebSocket by hashing it into Sec-WebSocket-Accept. websocket.Accept answers a plain GET with 426 Upgrade Required.
Proxies: Upgrade and Connection are hop-by-hop headers that proxies drop by default. nginx needs both set with proxy_set_header (and proxy_http_version 1.1 before 1.29.7); Go's httputil.ReverseProxy handles upgrades itself.
Idle timeouts: AWS ALB and nginx close a silent connection after 60 s by default, so ping every 20 to 30 s.
Origin: CORS does not apply to WebSockets and cookies go with the handshake, so check Origin, or any site can open a socket as your user.
Shutdown does not wait for hijacked connections: close them with StatusGoingAway and let clients reconnect with backoff.
TCP opens with SYN, SYN-ACK, ACK, one round trip before any data, and closes with a FIN and an ACK in each direction. The side that closes first then holds the socket in TIME_WAIT for 60 s on Linux, so a client that opens a new connection per request piles them up.
Why a handshake: each side picks a random initial sequence number and acknowledges the other's; sequence numbers let TCP order bytes and detect lost or duplicate segments.
Closing is per direction: a FIN means "I will send nothing more"; a RST aborts at once, which is connection reset by peer.
**Why TIME_WAIT: if the last ACK is lost, the peer resends its FIN and must get an answer, and stray packets of the old connection must expire before the same address and port pair is reused.
The trap**: each connection to one upstream takes an ephemeral port, about 28,000 of them on Linux. At 500 new connections a second, 60 s of TIME_WAIT needs 30,000, and dials fail with connect: cannot assign requested address.
The fix is reuse: one shared http.Client, enough MaxIdleConnsPerHost, bodies drained and closed. tcp_tw_reuse only hides the symptom; ss -tan state time-wait counts the sockets.
A recursive resolver walks from the root to the TLD to the domain's authoritative servers, and every answer is cached for its TTL. Go resolves a name only when it dials, so pooled keep-alive connections keep going to the old IP until they close; under steady traffic they never do.
Records: A and AAAA hold addresses, CNAME makes a name an alias of another; the TTL says how long a cache may keep the answer.
Caches on the way: the recursive resolver, the cluster's CoreDNS (30 s in the default config), sometimes the OS. Lower a TTL a day before a migration, so the old, longer one has expired everywhere.
Go keeps no DNS cache of its own: every dial resolves again. What lingers is connections: a busy HTTP/1.1 or HTTP/2 connection never goes idle, and a gRPC ClientConn re-resolves mainly when a connection breaks.
The fix: make connections end: MaxConnectionAge on a gRPC server (it sends GOAWAY), CloseIdleConnections() after a failover, and an old endpoint that closes its connections during the cut-over. IdleConnTimeout helps only when traffic pauses.
It builds a chain from the server's certificate through the intermediates to a root it already trusts, checks each certificate's validity dates, and checks that the hostname is in the leaf's Subject Alternative Names. For a private CA, add that CA to RootCAs; never switch verification off.
The chain: the server sends its leaf and the intermediates; the root must already be in the client's pool (the OS store, /etc/ssl/certs on Linux), and each signature is checked with the key of the certificate above it.
The name: Go matches the ServerName it dialled, also sent as SNI, against the SANs only; the Common Name is ignored.
The key: the server proves it holds the certificate's private key by signing the handshake, so a copied certificate alone is useless.
The errors: certificate signed by unknown authority is a missing root (a private CA, or a scratch image without CA files); certificate is valid for X, not Y is the name; has expired or is not yet valid is the dates, or a skewed clock.
**InsecureSkipVerify: true** skips both the chain and the name, so anyone on the path can present any certificate and read the traffic.
HTTP/3 runs over QUIC, a transport on UDP with TLS 1.3 built in: one round trip to connect instead of TCP's plus TLS's, streams that do not stall each other when a packet is lost, and connections that survive a change of IP. A Go API behind a load balancer rarely needs to speak it itself.
Handshake: TCP plus TLS 1.3 take two round trips before the request, QUIC one. 0-RTT resumption sends the request in the first packet, but that data can be replayed, so it is only for idempotent requests.
Head-of-line blocking: HTTP/2's streams share one TCP byte stream, so one lost segment stalls them all until it is resent. QUIC recovers loss per stream, which pays off most on lossy mobile networks.
Connection migration: a QUIC connection is identified by a connection ID, not by IP and port, so a phone moving from Wi-Fi to LTE keeps it.
Discovery: browsers start on TCP and switch after Alt-Svc: h3=":443"; where UDP is blocked they stay on HTTP/2.
In Go: net/http has no HTTP/3; quic-go provides http3.Server and http3.Transport. The usual setup is a CDN, Caddy or nginx speaking HTTP/3 at the edge and HTTP/1.1 or HTTP/2 to the Go service, where loss is low and the gain small.
An L4 balancer forwards TCP or UDP connections by IP and port without reading them, so it works for any protocol but balances whole connections. An L7 balancer terminates the connection and parses HTTP, so it can route by host, path or header and balance each request.
L4 sees IPs and ports only. TLS passes through to the backend, and any protocol works: Postgres, Kafka, raw TCP. Examples: AWS NLB, HAProxy in mode tcp, kube-proxy behind a ClusterIP Service.
L7 sees the path, headers, cookies and gRPC method. It can split /api from /static, terminate TLS, add X-Forwarded-For, retry and answer 502 itself, at the price of CPU and of holding the certificates. Examples: AWS ALB, nginx, Envoy.
Connections vs requests: L4 pins a long-lived HTTP/2 or gRPC connection to one backend, which is why gRPC behind a plain Service lands on one pod (see the microservices topic); L7 spreads the requests.
Health checks: L4 sees only whether the port accepts connections; L7 calls /readyz and reads the status.
Client IP: behind L4 the backend sees the balancer unless it preserves the source IP or speaks the PROXY protocol; behind L7 it reads X-Forwarded-For.
A forward proxy acts for clients and reaches any server, like a corporate egress proxy; a reverse proxy stands in front of servers, like nginx, an ingress or a load balancer. Behind one, r.RemoteAddr is the proxy, so read X-Forwarded-For and trust only the entries your own proxies added.
How the header grows: each proxy appends the address its connection came from: <anything the client sent>, <client>, <your proxy>. Everything left of your first proxy's entry is chosen by the client.
The classic bug: taking the leftmost entry. A client sends X-Forwarded-For: 1.2.3.4 and becomes 1.2.3.4 for your rate limiter, audit log and IP allowlist.
The rule: if RemoteAddr is not your proxy, ignore the header; otherwise walk it from the right, skip your proxies, and the first address left is the client.
Variants: Forwarded (RFC 7239), nginx's X-Real-IP, a CDN's CF-Connecting-IP. Trust the one your edge sets, and make the edge overwrite it, not pass it through.
Forward proxies in Go: http.DefaultTransport honours HTTPS_PROXY and NO_PROXY through http.ProxyFromEnvironment.
Store a slow, salted hash, never the password or a fast hash of it. SHA-256 is built for speed, so a leaked table falls to billions of guesses a second on one GPU; bcrypt and argon2id are slow on purpose, salted per password, and argon2id also needs a lot of memory for every guess.
Salt: random per password and stored with the hash, so equal passwords hash differently and precomputed tables are useless.
Cost: bcrypt at cost 12 takes about 200 ms per hash on a modern core, and each step doubles it. Invisible at login, ruinous for a word list.
argon2id (RFC 9106) is the current recommendation; OWASP's minimum is 19 MiB, t=2, p=1. argon2.IDKey returns only the key, so store the salt and the parameters with it.
bcrypt's limits: input over 72 bytes returns bcrypt.ErrPasswordTooLong, and its cost is CPU only. Under FIPS rules use PBKDF2, in the standard library as crypto/pbkdf2 since Go 1.24.
Compare with bcrypt.CompareHashAndPassword or subtle.ConstantTimeCompare. On a successful login, check bcrypt.Cost and rehash if you have raised the cost since.
Rate-limit the login endpoint: each hash holds a CPU core for hundreds of milliseconds.
A session is a random ID that points to state on the server, so you can revoke it at once; a JWT is a signed token checked without a lookup, so it scales without shared state but stays valid until it expires.
Sessions: logout and revocation are a delete; the cost is a store lookup (Redis, a database) on every request.
JWT: fix the signing algorithm in code instead of trusting the token's alg, and check exp, iss and aud. Keep access tokens short (5–15 minutes) and renew them with a refresh token.
Refresh tokens: store them server-side and rotate them on every use; reuse of an old one means theft, so revoke the whole chain.
In the browser: an HttpOnly; Secure; SameSite=Lax cookie keeps the token away from JavaScript and XSS; localStorage does not. Cookie auth needs CSRF protection, and Go 1.25 added http.CrossOriginProtection for it.
IDOR (insecure direct object reference): GET /orders/124 works for anyone who changes the number. Scope every query by the caller, WHERE id = $1 AND user_id = $2, and answer 404 rather than 403 so IDs cannot be probed.
golang-jwt checks exp only when the token has one: the claim is optional by default, so the script's token, minted without exp, is valid forever. Require it with jwt.WithExpirationRequired(), and pin the issuer and the audience too.
Why it passes: the script had the production secret, so the signature is valid, and with no exp, nbf or iat nothing time-based can fail. jwt.Parse returns no error.
**iss and aud**: nothing ties the token to this issuer or this API, so a staging token, or one for another service with the same secret, passes too. WithIssuer and WithAudience reject both, and a token missing the claim.
**WithValidMethods** pins the algorithm instead of trusting the header's alg. golang-jwt already refuses a key of the wrong type, but the pin states the intent and survives a later move to RSA keys.
Rotate the secret: the token already out there stays valid until the key changes. Give each environment its own key, and put a kid in the header so old and new keys can overlap during the switch.
The app sends the user to the identity provider, which signs them in and redirects back with a one-time code; the backend exchanges the code, plus a PKCE verifier only it knows, for tokens. OAuth grants access to APIs; OpenID Connect adds an ID token, a signed JWT that says who signed in.
The steps: /authorize with client_id, redirect_uri, scope, state and code_challenge; the user logs in; the browser returns to redirect_uri?code=...&state=...; the backend POSTs the code to /token.
**state is random, bound to the browser and checked on return, so nobody can make your app finish a login they started (login CSRF).
PKCE**: the app sends SHA-256(verifier) first and the verifier only at the exchange, so a code stolen from a redirect or a log is useless. It was made for apps that cannot keep a secret; OAuth 2.1 (still a draft) requires it from every client and lets a backend skip it only if it checks the OIDC nonce.
What OIDC adds: the openid scope, the ID token (sub, iss, aud equal to your client ID, exp), a userinfo endpoint, and discovery at /.well-known/openid-configuration with the signing keys. Verify the ID token, start your own session, and never send it to APIs as an access token.
It compares the signatures with !=, which is not constant-time, so timing can leak how much of a forged signature is right; and it signs only the body, so a captured request can be replayed forever. Compare with hmac.Equal, and have the sender sign a timestamp that you check.
Timing: != may stop at the first differing byte or word, so its duration depends on the input. Go compares in chunks and the leak is small, but secrets are compared in constant time as a rule: hmac.Equal takes the same time for any input of a given length. Decode the hex and compare bytes.
Replay: the HMAC proves the body came from the provider once, not that it is new, so one captured payment.succeeded delivery can be resent forever.
The fix: the sender signs timestamp.body; reject anything older than about five minutes and dedupe on the event ID inside that window. Stripe's t=...,v1=... header works this way; GitHub signs only the body, so there you dedupe on X-GitHub-Delivery.
Sign the raw bytes: verify the body exactly as received, before decoding; re-encoded JSON no longer matches.
The name from the query string goes straight into filepath.Join, which resolves ..: ?name=../../../etc/passwd becomes /etc/passwd, and ?name=../config.yaml the service's config. Open user-supplied names through an os.Root (Go 1.24+), which refuses any path that leaves the directory.
**Why ServeFile did not stop it**: it rejects .. in r.URL.Path only. Here the path is /download, and the dangerous part arrives in the query.
**Why strings.HasPrefix(full, filesDir) is no fix**: /srv/app/uploads-old/x passes it, and a symlink inside the directory can still point anywhere. filepath.IsLocal(name) is a correct lexical check but does not see symlinks either.
**os.Root** resolves every path component inside the directory, symlinks included, and returns path escapes from parent for anything outside. root.FS() gives the same guarantee as an fs.FS.
Directories: root.Open(".") succeeds, so check Stat and refuse directories before http.ServeContent.
Still needed: the IDOR rule applies to files too. Store uploads under generated ids, check that the caller owns the file, and use the original name only for Content-Disposition.
Server-side request forgery (SSRF) makes your server send requests the attacker cannot send directly: to the cloud metadata service, localhost admin ports or internal APIs. Check the IP at connect time in net.Dialer.Control, not the URL beforehand, so rebinding and redirects cannot slip past.
Why checking the URL is not enough
A hostname can resolve to 10.0.0.5, and check-then-fetch resolves twice: a rebinding DNS server with TTL 0 answers a public IP to the check and 127.0.0.1 to the dial.
A public URL can redirect to http://169.254.169.254/, and http.Client follows it.
[::ffff:127.0.0.1], 0.0.0.0 and names like localtest.me reach the local machine; no string filter on the host catches every form.
Checking the connection itself
Control runs on every dial, after resolution, with the exact IP being connected, so each redirect hop and each new pooled connection is checked too.
IsGlobalUnicast rules out loopback, link-local (the metadata address), multicast and 0.0.0.0 but allows 10.0.0.0/8, so add IsPrivate and 100.64.0.0/10, after Unmap.
Proxy: nil: through a proxy you dial the proxy, and the proxy fetches the internal URL for you.
Allow only https and the ports you expect, and cap redirects, time and response size.
Defence in depth
Never show the user the fetched body or a detailed error: that turns a blind SSRF into a port scanner.
Fetch from an isolated network segment or through an egress proxy with the same rule (Stripe's Smokescreen), and require IMDSv2 on AWS, so a plain GET to the metadata service gets nothing.
In mTLS the server also asks for a certificate, and each side verifies the other's chain against a private CA: the server sets ClientAuth: tls.RequireAndVerifyClientCert and ClientCAs, the client RootCAs, its own Certificates and ServerName. Then each checks which identity it got.
The fields on each side
Server: Certificates, ClientCAs and ClientAuth: tls.RequireAndVerifyClientCert. RequireAnyClientCert and VerifyClientCertIfGiven sound alike but accept an unverified or a missing certificate.
Client: Certificates, RootCAs with only the private CA, so a public CA's certificate for the same name is not enough, and a ServerName that is in the server's SAN.
Both: MinVersion: tls.VersionTLS13, never InsecureSkipVerify.
Authenticated is not authorised
A verified chain proves only that your CA issued the certificate. Check the identity, usually a SPIFFE URI SAN such as spiffe://prod/ns/billing/sa/api, against an allowlist: in VerifyConnection for the whole server, or per route from r.TLS.PeerCertificates[0].
Running it
Issue certificates for hours or days, automatically (cert-manager, SPIRE, Vault), and serve them through GetCertificate and GetClientCertificate, so a rotation needs no restart. Go checks no CRLs or OCSP: short lifetimes are the revocation.
Debug from the server's log: the client sees only a remote error: tls: ... alert (bad certificate, unknown certificate authority, certificate required), often on its first read rather than in the handshake.