Automatic FIPS 140-3 in Go: What Actually Works
Ideally, you want to build software once and run it anywhere. With FIPS and Go that can be tricky because in upstream, FIPS is baked in at build time. Using the Red Hat golang toolchain you can produce binaries that detect the FIPS mode of the underlying host, and automatically run in the right mode.

Go 1.26 introduced crypto/fips140 — a new standard library package giving Go
programs first-class awareness of FIPS 140-3 mode. Red Hat has been shipping a
patched Go toolchain in their UBI images for longer than that, with OpenSSL-backed
crypto that integrates with the Linux kernel’s FIPS flag.
But how do these two mechanisms interact? And which build combinations actually give you automatic, kernel-aware FIPS — meaning FIPS active on a FIPS-enabled host and transparent everywhere else, from the same binary?
We ran a systematic test across all meaningful build combinations on both a FIPS-enabled RHEL 10 host and a standard non-FIPS Linux host. This post shares what we found. The test program and Makefile are available at github.com/Jamstah/fips-go.
TL;DR
To build a Go binary that automatically uses FIPS on FIPS-enabled hosts and runs normally everywhere else, pick one:
Option A — OpenSSL backend (simplest, no runtime config):
FROM registry.access.redhat.com/ubi10/go-toolset:1.26 AS builder
RUN go build -o /myapp . # no GOFIPS140, no no_openssl
FROM registry.access.redhat.com/ubi10/ubi-minimal
COPY --from=builder /myapp /usr/local/bin/myapp
ENTRYPOINT ["/usr/local/bin/myapp"]
Option B — Go native FIPS module (fips140.Enabled() works correctly):
FROM registry.access.redhat.com/ubi10/go-toolset:1.26 AS builder
RUN go build -tags no_openssl -o /myapp .
FROM registry.access.redhat.com/ubi10/ubi-minimal
COPY --from=builder /myapp /usr/local/bin/myapp
ENV GODEBUG=fips140=auto
ENTRYPOINT ["/usr/local/bin/myapp"]
FIPS activates automatically when /proc/sys/crypto/fips_enabled=1, and
crypto/fips140.Enabled() correctly reflects the state.
Background: Two independent FIPS mechanisms
Before looking at results, it helps to understand that there are two completely separate ways Go can enforce FIPS on RHEL/UBI. They do not communicate with each other.
1. The Go native FIPS module (GOFIPS140)
GOFIPS140 is a build-time environment variable consumed by cmd/go. When set
to anything other than off, it compiles the FIPS 140-3 cryptographic module
into the binary and bakes a GODEBUG=fips140=on default into the binary at
link time. This default is not a runtime environment variable — it is embedded
in the binary and takes effect on every host, unconditionally.
The crypto/fips140.Enabled() function introduced in Go 1.26 reflects this
mechanism.
2. The Red Hat OpenSSL backend
The Red Hat ubi10/go-toolset image replaces Go’s standard crypto with an
OpenSSL-backed implementation by default (GOEXPERIMENT=opensslcrypto). OpenSSL
reads /proc/sys/crypto/fips_enabled at process startup and enforces FIPS when
the kernel flag is set — completely independently of GOFIPS140.
crypto/fips140.Enabled() is blind to this mechanism. A binary built with
GOFIPS140=off on a FIPS-enabled RHEL host will report fips140.Enabled() = false
while OpenSSL is actively enforcing FIPS.
Why you can’t combine them
Combining GOFIPS140=latest (or any non-off value) with the OpenSSL backend
panics at startup:
panic: opensslcrypto: GOLANG_FIPS and GODEBUG=fips140 are mutually exclusive
The Red Hat toolchain deliberately prevents both paths from being active
simultaneously. The no_openssl build tag disables the OpenSSL backend and
switches to the pure-Go FIPS module.
The test
We built a small Go program that:
- Prints build metadata from the binary’s embedded build info (Go version,
GOFIPS140, build tags) so each run is self-describing - Reports
crypto/fips140.Enabled() - Attempts an HTTPS connection to
3des.badssl.com, a host that only negotiates the non-FIPS 3DES cipher suite. The client is configured to offer only that suite (TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, TLS 1.2 max,InsecureSkipVerifysince cert validation is not what we’re testing). If anything has removed 3DES from the stack, the handshake fails — no runtime flags needed.
We tested eleven combinations: eight distinct image builds, plus three of those
re-run with GODEBUG=fips140=auto injected at runtime.
Results
The full matrix
| Target | GODEBUG at runtime |
fips140.Enabled() |
3DES on FIPS host | 3DES on non-FIPS host | FIPS mechanism |
|---|---|---|---|---|---|
golang:1.26, GOFIPS140=off |
— | false | succeeded | succeeded | none |
golang:1.26, GOFIPS140 unset |
— | false | succeeded | succeeded | none (unset == off on upstream) |
golang:1.26, GOFIPS140=latest |
— | true | blocked | blocked | Go native, unconditional |
ubi10/go-toolset, GOFIPS140=off |
— | false | blocked | succeeded | OpenSSL reads kernel flag |
ubi10/go-toolset, GOFIPS140 unset |
— | false | blocked | succeeded | same as above |
ubi10/go-toolset, GOFIPS140=off, no_openssl |
— | false | succeeded | succeeded | none |
ubi10/go-toolset, GOFIPS140 unset, no_openssl |
— | false | succeeded | succeeded | none |
ubi10/go-toolset, GOFIPS140=latest, no_openssl |
— | true | blocked | blocked | Go native, unconditional |
ubi10/go-toolset, GOFIPS140=off, no_openssl |
fips140=auto |
false/true | blocked | succeeded | Go native, kernel-aware |
ubi10/go-toolset, GOFIPS140 unset, no_openssl |
fips140=auto |
false/true | blocked | succeeded | Go native, kernel-aware |
ubi10/go-toolset, GOFIPS140=latest, no_openssl |
fips140=auto |
false/true | blocked | succeeded | auto overrides link-time on |
Key observations
crypto/fips140.Enabled() only reflects the Go native FIPS module. When
the OpenSSL backend is enforcing FIPS, fips140.Enabled() returns false. You
cannot use this API to check whether FIPS is active in an OpenSSL-backed binary.
GOFIPS140=latest is unconditional. The FIPS module is active on every
host, FIPS-enabled or not. There is no “activate only if the kernel has FIPS
enabled” behaviour in the upstream toolchain.
GODEBUG=fips140=auto overrides the link-time default in both directions.
On a GOFIPS140=latest binary running on a non-FIPS host, setting
GODEBUG=fips140=auto at runtime disables FIPS — 3DES succeeded and
fips140.Enabled() returned false.
The two approaches that give you automatic FIPS
If you want a binary that is FIPS-compliant on a FIPS host and runs normally elsewhere — without changing the binary between environments — there are two approaches that work today.
Approach 1: Build with the Red Hat toolset, keep the OpenSSL backend
This is the simplest approach and the default when using ubi10/go-toolset.
FROM registry.access.redhat.com/ubi10/go-toolset:1.26 AS builder
# Do NOT set GOFIPS140, do NOT add no_openssl
RUN go build -o /myapp .
FROM registry.access.redhat.com/ubi10/ubi-minimal
COPY --from=builder /myapp /usr/local/bin/myapp
ENTRYPOINT ["/usr/local/bin/myapp"]
At runtime, OpenSSL reads /proc/sys/crypto/fips_enabled and automatically
enforces FIPS when the kernel flag is set. On a non-FIPS host, it runs normally.
No build flags, no environment variables, no operator action required.
The catch: crypto/fips140.Enabled() returns false even when FIPS is
active, because enforcement is happening in OpenSSL, not the Go native module.
If your application code calls fips140.Enabled() to gate behaviour, it will
not work correctly with this approach.
Also note: for this to work at runtime, the container must link against OpenSSL
— meaning you need a runtime image that includes OpenSSL libraries (such as
ubi-minimal or ubi), not a scratch image.
Approach 2: Build with the Red Hat toolset, no_openssl, and set GODEBUG=fips140=auto at runtime
This approach uses the Go native FIPS module and activates it via the
GODEBUG=fips140=auto setting introduced by the Red Hat patches to the Go
toolchain.
FROM registry.access.redhat.com/ubi10/go-toolset:1.26 AS builder
# Disable OpenSSL backend to use the Go native FIPS module
RUN go build -tags no_openssl -o /myapp .
FROM registry.access.redhat.com/ubi10/ubi-minimal
COPY --from=builder /myapp /usr/local/bin/myapp
ENTRYPOINT ["/usr/local/bin/myapp"]
Then set GODEBUG=fips140=auto in your runtime environment:
# Kubernetes deployment
env:
- name: GODEBUG
value: fips140=auto
# Or directly
GODEBUG=fips140=auto ./myapp
At startup, the Go runtime reads /proc/sys/crypto/fips_enabled. If it is 1,
FIPS mode activates and fips140.Enabled() returns true. On a non-FIPS host,
auto resolves to off and the program runs normally.
The advantage over Approach 1: crypto/fips140.Enabled() correctly reflects
the FIPS state, so your application code can query it reliably.
The caveat: GODEBUG=fips140=auto overrides the link-time default in both
directions. If you set it in a base image or cluster-wide environment and also
deploy binaries built with GOFIPS140=latest, those binaries will have FIPS
disabled on non-FIPS hosts. Only use auto if you want kernel-aware activation.
If you need FIPS unconditionally, use GOFIPS140=latest and do not set a
runtime GODEBUG override.
A note on GOFIPS140 unset with the Red Hat toolchain
The golang-fips patch
is designed to make GOFIPS140 unset default to v1.0.0 (a certified snapshot)
with GODEBUG=fips140=auto baked in at link time — giving automatic
kernel-aware activation with no runtime configuration needed. When this ships,
Approach 2 above would not require any runtime env var at all.
However, ubi10/go-toolset:1.26 does not yet implement this default:
go env GOFIPS140 returns off when unset. Until the patch ships in the image,
GODEBUG=fips140=auto must be set at runtime as shown above.
What about the upstream golang:1.26 image?
The upstream golang:1.26 image does not include the Red Hat OpenSSL patches.
GOFIPS140 unset is treated as off. The only way to get FIPS is
GOFIPS140=latest, which activates it unconditionally on all hosts.
If you need a kernel-aware, automatic FIPS behaviour with the upstream image,
you would need to detect /proc/sys/crypto/fips_enabled yourself at startup and
set GODEBUG=fips140=on programmatically — but that is not standard Go
behaviour and outside the scope of this post.
For FIPS requirements, the Red Hat UBI toolset images are the right choice.
Summary
| Goal | Approach |
|---|---|
| FIPS always active, all hosts | GOFIPS140=latest, no_openssl, no runtime override |
FIPS only on FIPS hosts (kernel-aware), fips140.Enabled() accurate |
no_openssl, GODEBUG=fips140=auto at runtime |
| FIPS only on FIPS hosts (kernel-aware), simpler setup | Default UBI toolset build (OpenSSL backend, no no_openssl) |
| No FIPS | GOFIPS140=off or upstream image |
For production workloads that need to run on both FIPS and non-FIPS hosts from a
single image, either the OpenSSL default or the GODEBUG=fips140=auto approach
will work today. The OpenSSL approach requires no runtime configuration but does
not expose FIPS state through crypto/fips140.Enabled(). The auto approach
gives you accurate fips140.Enabled() reporting but requires setting a runtime
environment variable until the v1.0.0 default patch ships in the toolchain.
The test code, Dockerfiles, and Makefile for all eleven combinations tested here are available at github.com/Jamstah/fips-go.