Lab SE1: Authentication and TLS
Prerequisite reading: Security — Intensive, specifically the "four seams" model, the authc chain, and the TLS section.
Background
This lab makes the authentication and TLS seams concrete. You will bring up
a single secured OpenSearch node using the security demo configuration (which
ships demo certificates and a demo internal_users set), authenticate with HTTP
Basic, watch the authc chain pick the matching link, inspect both TLS layers
(transport 9300 and http 9200), add a new internal user, add a second authenticator
(JWT) to the chain, and finally break TLS on purpose to read the handshake error.
Nothing here mutates a real cluster — it is a throwaway demo node you can delete afterward.
Why this matters for contributors
Every bug report that starts with "I can't connect", "401 on everything", or
"securityadmin won't apply" is an authn-or-TLS bug, and the security repo's issue
tracker is full of them. To triage one you must be able to: read config.yml's
authc chain, know which authenticator should have matched, find where the user
lands in the ThreadContext, and tell a TLS-handshake failure apart from an
authn failure. This lab builds that exact reflex against running code, and points
you at the BackendRegistry source so you can read the path your request takes.
Prerequisites
- A JDK 21+ and a local OpenSearch checkout or a downloaded distribution tarball
(the security plugin and its
tools/scripts ship in the default distribution). curl,openssl, andkeytoolonPATH.- Optional but recommended: a clone of the security source to grep —
git clone https://github.com/opensearch-project/security.
# Confirm you have a distribution with the security plugin (not a bare server build):
ls plugins/ | grep -i security # opensearch-security
ls plugins/opensearch-security/tools/ # install_demo_configuration.sh securityadmin.sh
Note: A bare
./gradlew runofserver/has no security plugin. For this lab use a full distribution tarball (opensearch-minplus plugins, or the standard distribution). If you only have the server checkout, download a matching distribution release to getplugins/opensearch-security/.
Step-by-step tasks
Step 1 — Install the demo configuration
The demo installer generates self-signed demo certs (root-ca.pem, esnode.pem,
esnode-key.pem, the admin cert kirk.pem), writes the TLS settings into
opensearch.yml, and seeds the demo internal_users (including admin).
cd <distribution-root>
# Non-interactive; -y accept, -i initialize, -c cluster-mode off for a single node:
./plugins/opensearch-security/tools/install_demo_configuration.sh -y -i -s
Inspect what it wrote into opensearch.yml:
grep -n "plugins.security" config/opensearch.yml
You should see (abbreviated):
plugins.security.ssl.transport.pemcert_filepath: esnode.pem
plugins.security.ssl.transport.pemkey_filepath: esnode-key.pem
plugins.security.ssl.transport.pemtrustedcas_filepath: root-ca.pem
plugins.security.ssl.transport.enforce_hostname_verification: false
plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.pemcert_filepath: esnode.pem
plugins.security.ssl.http.pemkey_filepath: esnode-key.pem
plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem
plugins.security.allow_default_init_securityindex: true
plugins.security.authcz.admin_dn:
- 'CN=kirk,OU=client,O=client,L=test,C=de'
-
Identify the two TLS namespaces (
...ssl.transport.*vs...ssl.http.*) and the admin DN that will be allowed to runsecurityadmin.sh.
Step 2 — Start the node and confirm both TLS layers are up
./bin/opensearch & # or run in another terminal
# Wait for it to bind. Then test http TLS (note: https, and we trust the demo CA):
curl -sk -u admin:<demo-admin-password> https://localhost:9200/ | head
-k skips cert verification (demo CA is self-signed). To do it properly — which
is the point of TLS — trust the demo CA explicitly and drop -k:
curl -s --cacert config/root-ca.pem -u admin:<demo-admin-password> \
https://localhost:9200/_cluster/health?pretty
If you get a JSON cluster health back over https, http TLS works. Transport TLS (9300) is proven by the fact the node formed a cluster at all — it cannot without it when security is on.
-
Confirm the URL scheme is
https, nothttp. A plainhttp://localhost:9200will fail the handshake — that is http TLS doing its job.
Step 3 — Authenticate with HTTP Basic and inspect the chain
Hit the "who am I?" endpoint. This shows exactly what BackendRegistry built for
you and stored in the ThreadContext:
curl -s --cacert config/root-ca.pem -u admin:<demo-admin-password> \
https://localhost:9200/_plugins/_security/authinfo?pretty
{
"user": "User [name=admin, backend_roles=[admin], requestedTenant=null]",
"user_name": "admin",
"backend_roles": ["admin"],
"roles": ["all_access", "security_rest_api_access"],
"tenants": { "global_tenant": true, "admin": true },
"principal": null,
"remote_address": "127.0.0.1:xxxxx"
}
Read the demo config.yml to see which authc link matched your Basic credential:
grep -n "authc:\|http_authenticator\|type:\|authentication_backend\|order:" \
config/opensearch-security/config.yml
The basic_internal_auth_domain (order 0, type: basic, backend type: internal)
is the one that matched: HTTPBasicAuthenticator extracted admin:... from the
Authorization: Basic header, and InternalAuthenticationBackend bcrypt-checked it
against internal_users.yml.
-
Now send a wrong password and observe the
401:
curl -s -o /dev/null -w "%{http_code}\n" --cacert config/root-ca.pem \
-u admin:WRONG https://localhost:9200/_plugins/_security/authinfo
# 401
- Read where the user is injected (in a security source checkout):
grep -rn "putTransient\|OPENDISTRO_SECURITY_USER\|class BackendRegistry\|authenticate(" \
src/main/java/org/opensearch/security/auth/BackendRegistry.java \
src/main/java/org/opensearch/security/support/ConfigConstants.java | head
You are looking for the line where the authenticated User is put into the
ThreadContext under the transient key _opendistro_security_user. Everything
downstream (authz, DLS) reads it from there.
Step 4 — Add a new internal user (two ways)
Way A — via the REST admin API (cluster already up):
curl -s --cacert config/root-ca.pem -u admin:<demo-admin-password> \
-XPUT https://localhost:9200/_plugins/_security/api/internalusers/alice \
-H 'Content-Type: application/json' -d'
{
"password": "Alice-Strong-Passw0rd!",
"backend_roles": ["ops-team"],
"attributes": { "dept": "fulfillment" }
}'
Verify Alice can authenticate:
curl -s --cacert config/root-ca.pem -u alice:Alice-Strong-Passw0rd! \
https://localhost:9200/_plugins/_security/authinfo?pretty | head
# user_name: alice, backend_roles: [ops-team], roles: [] (no roles mapped yet — that's Lab SE2)
Way B — via YAML + securityadmin.sh (the production / GitOps way). Edit
config/opensearch-security/internal_users.yml, add a bob block with a bcrypt
hash, then load it:
# Generate a bcrypt hash for the password:
./plugins/opensearch-security/tools/hash.sh -p 'Bob-Strong-Passw0rd!'
# Paste the hash under bob: in internal_users.yml, then apply the whole config dir:
./plugins/opensearch-security/tools/securityadmin.sh \
-cd config/opensearch-security/ \
-icl -nhnv \
-cacert config/root-ca.pem \
-cert config/kirk.pem \
-key config/kirk-key.pem
-
Confirm
bobnow authenticates the same way. Note thatsecurityadmin.shtalked to 9300 with the admin cert — it never used a password.
Step 5 — Add a second authenticator (JWT) to the chain
Append a JWT domain after the basic domain in config.yml, so Basic is tried
first and JWT second:
# config/opensearch-security/config.yml, under config.dynamic.authc:
jwt_auth_domain:
description: "HMAC-signed JWT bearer tokens"
http_enabled: true
transport_enabled: false
order: 1
http_authenticator:
type: jwt
challenge: false
config:
signing_key: "<base64 of your HMAC secret>"
jwt_header: "Authorization"
subject_key: "sub"
roles_key: "roles"
authentication_backend:
type: noop
Apply it and mint a token to test:
./plugins/opensearch-security/tools/securityadmin.sh -cd config/opensearch-security/ \
-icl -nhnv -cacert config/root-ca.pem -cert config/kirk.pem -key config/kirk-key.pem
# Mint an HS256 JWT (sub=alice) with your secret, then:
curl -s --cacert config/root-ca.pem \
-H "Authorization: Bearer $JWT" \
https://localhost:9200/_plugins/_security/authinfo?pretty | head
# user_name: alice (resolved from the JWT 'sub' claim) — no Basic creds sent
The chain logic: BackendRegistry tries basic_internal_auth_domain first; with
no Authorization: Basic header it falls through to jwt_auth_domain, which
validates the signature and reads the sub claim. challenge: false on the JWT
domain means it will not send a WWW-Authenticate back — important so the two
domains don't fight over the 401 challenge.
-
Confirm: a request with Basic creds matches link 0; a request with a
Bearer token matches link 1; a request with neither gets
401.
Step 6 — Break TLS on purpose
Demonstrate a bad-cert handshake failure so you can recognize it in the wild. Connect with the wrong CA (use the system CA bundle instead of the demo CA):
# Wrong trust store -> handshake fails BEFORE any authn:
curl -v https://localhost:9200/ -u admin:<demo-admin-password> 2>&1 | grep -i "ssl\|certificate\|handshake" | head
# ... SSL certificate problem: self-signed certificate / unable to get local issuer certificate
Contrast the two failure classes — this distinction is the whole point:
| You see | Layer that failed | Fix |
|---|---|---|
SSL certificate problem / handshake failure (no HTTP status) | TLS — connection never established | trust the right CA (--cacert root-ca.pem); check ssl.http.* |
HTTP 401 Unauthorized | authn — TLS fine, credential rejected | fix the credential / authc chain |
HTTP 403 Forbidden | authz — authn fine, no privilege (Lab SE2) | fix roles/mappings |
- Produce all three: a TLS failure (wrong CA), a 401 (wrong password), and note that 403 comes in Lab SE2.
Deliverables
-
The
opensearch.ymlTLS block, annotated: which lines are transport, which are http, and which is the admin DN. -
The
/_plugins/_security/authinfooutput foradminand foralice, showing the difference inbackend_roles/roles. -
The
config.ymlauthc chain after adding the JWT domain, with theorder:values, and a one-line explanation of which link matches Basic vs Bearer. -
Three captured failures: a TLS handshake error, a 401, and the grep line
where
BackendRegistryinjects the user into theThreadContext.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
curl: (60) SSL certificate problem | not trusting the demo CA | add --cacert config/root-ca.pem (or -k for demo only) |
curl: (52) Empty reply on http://...:9200 | http TLS is on; you used plain http | use https:// |
401 even with the right password | wrong authc order / domain disabled / password reset needed | check config.yml order:; re-hash and re-apply |
securityadmin.sh: "Unable to connect to opensearch" | transport TLS / admin cert mismatch | verify -cacert/-cert/-key match ssl.transport.* and admin_dn |
securityadmin.sh: "Connection refused on 9300" | wrong host/port | pass -h localhost -p 9300; confirm transport bound |
| Config applied but no effect | wrote to the wrong cluster / stale index | re-run securityadmin with -icl; check the .opendistro_security index exists |
| JWT always 401 | bad signing_key, wrong alg, or clock skew | base64 secret matches the signer; check iat/exp; subject_key matches a claim |
# securityadmin connectivity is a TLS problem 9 times out of 10 — confirm the chain:
openssl s_client -connect localhost:9300 -cert config/kirk.pem -key config/kirk-key.pem \
-CAfile config/root-ca.pem </dev/null 2>&1 | grep -i "verify\|return code"
Expected output
https://localhost:9200/_cluster/healthreturns JSON with--cacert, fails with the wrong CA.authinfoforadminshowsroles: [all_access, ...]; for a freshaliceshowsroles: [](no mapping yet — that is Lab SE2's job).- A wrong password yields
401; a wrong CA yields a TLS error with no HTTP status.
Stretch goals
-
Add a PKI (clientcert) authenticator: configure a
clientcertauthc domain and authenticate withcurl --cert client.pem --key client-key.pemand no-u. ReadHTTPClientCertAuthenticatorto see how the cert DN becomes the username. -
Stand up a tiny OIDC provider (e.g. a local Keycloak) and wire an
openidauthc domain; watch Security fetch the JWKS and validate the ID token. TraceHTTPJwtKeyByOpenIdConnectAuthenticator. -
Turn on audit logging to an index and re-run your 401/200 requests; query
auditlog-*and find theFAILED_LOGINandAUTHENTICATEDevents.
Coding Exercises
You drove the authc chain and TLS with curl; now make the behaviour executable.
These target a clone of opensearch-project/security (git clone … && cd security).
Find every class/method with rg first
(rg -l "class BackendRegistry" src/main); the security repo's signatures drift, so
never trust a stale line number.
-
(warm-up) Unit-test
HTTPBasicAuthenticatorcredential extraction. Find the existing test (rg -l "HTTPBasicAuthenticator" src/test) and add a case that builds aSecurityRequestmock with anAuthorization: Basic <base64(admin:secret)>header, callsextractCredentials(...), and asserts the returnedAuthCredentialshas usernameadmin. Add a second assertion: a request with no Basic header returnsnull(so the chain falls through). Verify with./gradlew test --tests "*HTTPBasicAuthenticator*". -
(core) Assert
InternalAuthenticationBackendrejects a wrong password. Locate the backend and its test (rg -l "class InternalAuthenticationBackend" src; rg -l "InternalAuthenticationBackendTest" src/test). Add a test that seeds an internal user with a bcrypt hash, then assertsauthenticate(...)succeeds for the right password and throwsOpenSearchSecurityExceptionfor the wrong one — the in-code form of the 401 you produced in Step 3. -
(core) Assert the user lands in the
ThreadContextunder the right key. In Step 3 you grepped for whereBackendRegistryinjects the authenticatedUser. Turn it into a test: driveBackendRegistry.authenticate(...)(or the smallest reachable seam) with a valid Basic credential and assert theThreadContexttransient underConfigConstants.OPENDISTRO_SECURITY_USERholds aUserwith nameadmin. Read the constant name fromrg -n "OPENDISTRO_SECURITY_USER" src/main/java/org/opensearch/security/support/ConfigConstants.java. -
(core) A security integration test: 401 vs 200. The security repo ships an integration-test framework (find it with
rg -l "LocalCluster|class SingleClusterTest|extends.*IntegrationTest" src/integrationTest src/test). Write an integ test that boots a secured single node with the demo-styleinternal_users, then asserts an authenticated request to/_plugins/_security/authinforeturns 200 and a wrong-password request returns 401. This is the chain you exercised in Step 3, now self-contained and CI-runnable. -
(core) Two-link-chain test: Basic then JWT fall-through. Model Step 5 in code. In an integration test, configure a
config.ymlwithbasic_internal_auth_domain(order 0) and ajwt_auth_domain(order 1,challenge: false). Assert that (a) Basic creds authenticate via link 0, (b) a valid Bearer JWT authenticates via link 1, and (c) neither yields a 401. Read how an existing JWT test mints a token (rg -l "Jwts.builder|HS256|signing_key" src/test src/integrationTest). -
(advanced) Add a
clientcertPKI authenticator path and prove DN→username. Advanced challenge: building on the Stretch goal, configure aclientcertauthc domain and write an integration test that authenticates with a client cert (no-u, no token) and asserts the resolveduser_nameequals the username derived from the cert DN. ReadHTTPClientCertAuthenticator(rg -l "class HTTPClientCertAuthenticator" src/main) to see which DN field becomes the username, then make the test assert exactly that mapping — and a second case where a cert whose DN does not match the configured pattern is rejected. The deliverable is one integ test proving cert-DN authentication end to end, the same SPI shape Lab SE3 extends.
Issues to Practice On
Authn/TLS issues dominate the opensearch-project/security tracker. Hunt them (labels
move; confirm on the tracker):
gh issue list --repo opensearch-project/security --label "good first issue" --state open
gh issue list --repo opensearch-project/security --label "bug" --state open --search "TLS OR certificate OR JWT OR authentication OR 401"
gh issue list --repo opensearch-project/security --label "triage" --state open
gh label list --repo opensearch-project/security | grep -iE "auth|tls|ssl|jwt|good first"
Representative patterns. (1) "securityadmin.sh can't connect / handshake fails" —
almost always transport TLS or admin-cert/DN mismatch: reproduce with the
openssl s_client check from Troubleshooting, locate the config read via rg, fix the
cert/DN, and add a test. (2) "JWT always 401" — bad signing_key/alg/clock skew:
reproduce in an integration test, trace the validation in the JWT authenticator, fix,
and ship the test. Arc: reproduce → locate via rg → fix → test → PR with a
CHANGELOG.md entry and DCO sign-off.
Planted-bug drill. In HTTPBasicAuthenticator.extractCredentials (locate with
rg -n "extractCredentials" $(rg -l "class HTTPBasicAuthenticator" src/main)), change
the header it reads from Authorization to a typo (e.g. Authorisation). Run
./gradlew test --tests "*HTTPBasicAuthenticator*" and watch which test goes red — that
test is the guard for every Basic login. Revert, then add the "no header ⇒ null creds"
assertion from Exercise 1 so the fall-through contract is pinned.
Etiquette: claim the issue first, reproduce before theorising, and every PR ships a test +
CHANGELOG.mdentry + DCOSigned-off-by(git commit -s). See community-interaction.
Validation / self-check
-
Explain, for a single
curl -u alice:... https://..., the exact order: TLS handshake →SecurityRestFilter→BackendRegistry→ which authc link matched → where theUserwas stored. - State the difference between a TLS failure and a 401 at the wire level (one has no HTTP status, the other does) and why.
-
Show why transport TLS is non-optional by reasoning about how
securityadminand cross-node user propagation depend on it. -
Point at the
grepline inBackendRegistrywhere the user enters theThreadContext, and name the transient header key.