Skip to main content
Version: v1.2.0

Secure deployments

Deployments authenticate scoring requests through one or more SecurityOptions entries. Carrying two or more entries lets you mix mechanisms (for example, OIDC alongside a passphrase) and rotate credentials without downtime.

Prerequisites

Before you begin, complete the following steps:

  1. Import the necessary Python packages. For instructions, see Step 1: Import the required packages.
  2. Connect to H2O MLOps. For instructions, see Connect to H2O MLOps.
  3. Create a workspace. For instructions, see Create a workspace.
  4. Create an experiment. For instructions, see Create an experiment.
  5. Create a model and register the experiment with it. For instructions, see Register an experiment with a model.

The examples below assume workspace, comp_opts, model, and scoring_runtime are already defined.

Security types

The table below describes each SecurityType value, when to use it, and any trade-offs to consider.

SecurityTypeUse whenNotes
DISABLEDTesting or internal-only deployments that require no authentication.Endpoint is publicly accessible. Do not use in production.
PLAIN_PASSPHRASEYou accept passphrase exposure in transit.The passphrase travels in clear text. Less secure than the hashed variant.
HASHED_PASSPHRASEYou want passphrase authentication with stronger server-side storage.Uses PBKDF2 hashing. The actual secret does not retransmit after create.
OIDC_AUTHYou need token-based authentication integrated with your identity provider.Requires server-side configuration. See Endpoint security.
API_KEYYou need a high-entropy, server-generated service-to-service credential.Server returns the plaintext exactly once on the create response. See Rotate an API_KEY.
note

Not all security types work in every environment. Cluster operators configure the allowed types.

To check the allowed types using the H2O MLOps Python client, run: mlops.configs.allowed_security_types.

API_KEY requires "API_KEY" in securityOptions.activated in your values.yaml. Without it, the server rejects API_KEY entries with "unsupported api_key security option in current environment".

Multi-entry security model

A deployment can carry one or more SecurityOptions entries. At scoring time, any single entry that passes authenticates the request. The server evaluates entries independently.

A naming convention to know up front: the read-side property is deployment.securities_options (plural), and the keyword argument on create() and update() is security_options= (singular), even when you pass a list. Avoid the deprecated singular deployment.security_options, which returns only the first entry.

Each entry carries an optional name (a human-readable label) and a server-assigned uid (stable identifier). When updating, read entries from deployment.securities_options to get the uid values you need for EDIT and DELETE actions. See the SecurityOptions field reference for the full field list.

Read the security entries on a deployment

Use the securities_options property (plural) to read the current list of entries. Code examples in this page reuse the _print_entries(dep) helper defined below:

def _print_entries(dep):
for s in dep.securities_options:
print(f" uid={s.uid!r} type={s.security_type.name} name={s.name!r}")


_print_entries(deployment)
Deprecated: security_options (singular)

deployment.security_options returns only the first entry and emits a DeprecationWarning:

deployment.security_options is deprecated and returns only the first security entry. Use deployment.securities_options instead.

Use deployment.securities_options (plural) in all new code.

Constraints on the entry list

The server enforces these constraints on the entry list. They apply to both create and update calls.

  • DISABLED cannot coexist with other entries. The server rejects a list that mixes DISABLED with any other security type: "disabled_security cannot be combined with other security options".
  • Combined cap of 100 passphrase and API_KEY entries. The server caps the combined count of PLAIN_PASSPHRASE, HASHED_PASSPHRASE, and API_KEY entries on a single deployment at 100. OIDC entries do not count toward this cap. Exceeding the cap returns "number of passphrase/api_key entries must not exceed 100".
  • A deployment must carry at least one entry. The server rejects an update that would leave the deployment with no entries: "missing authentication type". To switch a secured deployment to no authentication, replace the entry list with a single DISABLED entry rather than emptying it.

Create a deployment with security

Single entry (shorthand)

Pass a single SecurityOptions instance to create a one-entry secured deployment. The read-side returns the entry with a server-assigned uid.

from h2o_mlops.options import SecurityOptions, CompositionOptions
from h2o_mlops.types import DeploymentModeType, SecurityType

sec_single = SecurityOptions(
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="single-entry-secret",
name="default-passphrase",
)

dep_single = workspace.deployments.create(
name="security-demo-single",
composition_options=[comp_opts],
mode=DeploymentModeType.SINGLE_MODEL,
security_options=sec_single,
)

_print_entries(dep_single)

# Scoring uses the passphrase as auth value:
dep_single.scorer.score(
auth_value="single-entry-secret",
payload=dep_single.scorer.sample_request(auth_value="single-entry-secret"),
)

Two or more entries

Pass a list of SecurityOptions instances. Each entry independently authenticates incoming requests.

from h2o_mlops.options import SecurityOptions, CompositionOptions
from h2o_mlops.types import DeploymentModeType, SecurityType

sec_oidc = SecurityOptions(
security_type=SecurityType.OIDC_AUTH,
name="company-oidc",
)
sec_pass = SecurityOptions(
security_type=SecurityType.HASHED_PASSPHRASE,
passphrase="initial-secret",
name="hashed-passphrase",
)

dep_multi = workspace.deployments.create(
name="security-demo-multi",
composition_options=[comp_opts],
mode=DeploymentModeType.SINGLE_MODEL,
security_options=[sec_oidc, sec_pass],
)

_print_entries(dep_multi)

# Either credential is accepted at scoring time:
dep_multi.scorer.score(
auth_value="initial-secret",
payload=dep_multi.scorer.sample_request(auth_value="initial-secret"),
)
dep_multi.scorer.score(
auth_value="<oidc-bearer-token>",
payload=dep_multi.scorer.sample_request(auth_value="<oidc-bearer-token>"),
)

Create with API_KEY

The server generates the API key. Set security_type=SecurityType.API_KEY and leave api_key unset. The client raises ValueError if you try to supply one.

from h2o_mlops.options import SecurityOptions, CompositionOptions
from h2o_mlops.types import DeploymentModeType, SecurityType


sec_api_key = SecurityOptions(
security_type=SecurityType.API_KEY,
name="service-key",
)

dep_api_key = workspace.deployments.create(
name="security-demo-api-key",
composition_options=[comp_opts],
mode=DeploymentModeType.SINGLE_MODEL,
security_options=sec_api_key,
)

# Read the plaintext now (see the caution below).
plaintext = dep_api_key.securities_options[0].api_key
print(f"API key (save this now): {plaintext!r}")

# api_key_prefix is safe to log on every read.
prefix = dep_api_key.securities_options[0].api_key_prefix
print(f"API key prefix: {prefix!r}")

# Use the plaintext as auth_value for scoring:
dep_api_key.scorer.score(
auth_value=plaintext,
payload=dep_api_key.scorer.sample_request(auth_value=plaintext),
)
caution

The create response carries api_key in plaintext exactly once. Later reads return None for api_key; only api_key_prefix is available for identification. Store the plaintext securely before proceeding.

Update security on an existing deployment

Each entry in an update sets action (ADD, EDIT, or DELETE) to indicate the per-entry operation. All operations in the list bundle into a single PATCH and trigger a single redeployment.

ADD

Insert a new entry.

from h2o_mlops.types import SecurityAction

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.ADD,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="granular-added-secret",
name="granular-add",
),
],
)

EDIT to rotate or rename

Fetch the current entries to get their server-assigned uid values, then submit both edits in one call.

from h2o_mlops.types import SecurityAction


entries = dep_multi.securities_options
granular_added = next(s for s in entries if s.name == "granular-add")
oidc_entry = next(s for s in entries if s.security_type == SecurityType.OIDC_AUTH)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.EDIT,
uid=granular_added.uid,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="granular-rotated-secret", # supplying a value re-hashes
name="granular-add",
),
SecurityOptions(
action=SecurityAction.EDIT,
uid=oidc_entry.uid,
security_type=SecurityType.OIDC_AUTH,
name="company-oidc-renamed", # rename only
),
],
)

EDIT to preserve a stored hash

Set passphrase=None on a passphrase-type EDIT to keep the existing hash and change only metadata such as name.

from h2o_mlops.types import SecurityAction


hashed = next(
s for s in dep_multi.securities_options
if s.security_type == SecurityType.HASHED_PASSPHRASE
)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.EDIT,
uid=hashed.uid,
security_type=SecurityType.HASHED_PASSPHRASE,
passphrase=None, # keep the stored hash
name="hashed-passphrase-renamed",
),
],
)

# Original secret still works:
dep_multi.scorer.score(
auth_value="initial-secret",
payload=dep_multi.scorer.sample_request(auth_value="initial-secret"),
)

DELETE

from h2o_mlops.types import SecurityAction


target = next(
s for s in dep_multi.securities_options if s.name == "granular-add"
)
dep_multi.update(
security_options=[
SecurityOptions(action=SecurityAction.DELETE, uid=target.uid),
],
)

Combined operations in one PATCH

Bundle ADD, EDIT, and DELETE into a single call for atomic rotation: one PATCH, one redeployment. The deployment never loses the entries that remain valid.

ADD + EDIT + DELETE:

from h2o_mlops.types import SecurityAction


entries = dep_multi.securities_options
oidc = next(s for s in entries if s.security_type == SecurityType.OIDC_AUTH)
hashed = next(s for s in entries if s.security_type == SecurityType.HASHED_PASSPHRASE)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.ADD,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="combined-added-secret",
name="combined-add",
),
SecurityOptions(
action=SecurityAction.EDIT,
uid=oidc.uid,
security_type=SecurityType.OIDC_AUTH,
name="company-oidc-final",
),
SecurityOptions(action=SecurityAction.DELETE, uid=hashed.uid),
],
)

Granular security combined with kubernetes_options:

You can send security operations alongside non-security updates in the same call: one PATCH, one redeployment.

from h2o_mlops.options import KubernetesOptions
from h2o_mlops.types import SecurityAction


target = next(
s for s in dep_multi.securities_options if s.name == "combined-add"
)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.EDIT,
uid=target.uid,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase=None, # preserve the stored hash
name="combined-add-renamed",
),
],
kubernetes_options=KubernetesOptions(replicas=2),
)

Rotate an API_KEY

An EDIT on an API_KEY entry silently preserves the existing key. The server does not generate a new key on update and does not raise an error. To rotate:

  1. In a single update() call, ADD a new API_KEY entry and DELETE the old one. Bundling both operations keeps the deployment continuously authenticated through one PATCH.
  2. Read the new plaintext from dep.securities_options immediately after the call returns.
from h2o_mlops.types import SecurityAction


# Find the existing API_KEY entry to retire.
old_key = next(
s for s in dep_api_key.securities_options
if s.security_type == SecurityType.API_KEY
)

dep_api_key.update(
security_options=[
SecurityOptions(
action=SecurityAction.ADD,
security_type=SecurityType.API_KEY,
name="service-key-rotated",
),
SecurityOptions(action=SecurityAction.DELETE, uid=old_key.uid),
],
)

# Read the new plaintext immediately from the response.
new_plaintext = next(
s.api_key for s in dep_api_key.securities_options
if s.name == "service-key-rotated"
)
print(f"New API key (save this now): {new_plaintext!r}")

SecurityOptions field reference

FieldTypeNotes
security_typeSecurityType | NoneRequired for most operations. May be None only when action == SecurityAction.DELETE.
passphrasestr | NoneRequired for PLAIN_PASSPHRASE and HASHED_PASSPHRASE. Must be None for DISABLED, OIDC_AUTH, and API_KEY (raises ValueError otherwise). On EDIT, None preserves the stored hash; supplying a value re-hashes.
api_keystr | NoneRead-only. Server-populated on the create response, exactly once. Must be None on create and update (raises ValueError otherwise).
api_key_prefixstr | NoneRead-only. Short display prefix returned on every read. Safe to include in audit logs.
namestr | NoneOptional human-readable label. Not used for identity or deduplication.
uidstr | NoneServer-assigned. Leave unset on create. Required (positive-int string) for EDIT and DELETE.
actionSecurityAction | NonePer-entry operation tag (ADD, EDIT, or DELETE) for update calls. Required on every entry in an update list; all entries in a single update must consistently set action.

SecurityAction enum reference

SecurityActionWire valueConstraints
ADD"add"Leave uid as None; the server assigns one. Provide security_type. For passphrase types, provide a non-empty passphrase.
EDIT"edit"Provide a positive-int uid matching an existing entry. Provide security_type. For passphrase types, passphrase=None preserves the stored hash; a non-None value re-hashes.
DELETE"delete"Provide a positive-int uid matching an existing entry. The server ignores all other fields. security_type may be None.

All entries in a single update must consistently set action. Mixing tagged and untagged entries raises ValueError before any HTTP call.

Validation rules

The client enforces all rules below before any HTTP call. The deployment stays untouched on any ValueError.

FailureWhyExact message (partial)
Mixing tagged and untagged entriesAll entries in a single update must consistently set action."All SecurityOptions entries must have an action, or none of them must."
ADD with non-None uidThe server assigns the uid; clients must not supply one."SecurityOptions with action=ADD must have uid=None..."
ADD with missing security_typeRequired to know what to insert."SecurityOptions with action=ADD requires security_type to be set."
ADD with passphrase type but empty passphraseA passphrase entry without a passphrase is incomplete."SecurityOptions with action=ADD and security_type=... requires a non-empty passphrase."
EDIT with missing security_typeRequired to know what to write back."SecurityOptions with action=EDIT requires security_type to be set."
EDIT or DELETE with missing or non-integer uidMust be a positive-int string."SecurityOptions with action=... requires uid to be set (positive-integer string)." / "SecurityOptions.uid ... must be a positive integer string for action=..."
Duplicate uid across actions in one callAmbiguous which operation wins."duplicate uid ... across EDIT/DELETE entries; each uid may appear at most once per update."

The following example triggers the mixed-mode ValueError:

from h2o_mlops.types import SecurityAction


# Raises ValueError before any HTTP call:
dep_multi.update(
security_options=[
SecurityOptions(action=SecurityAction.ADD, security_type=SecurityType.OIDC_AUTH),
SecurityOptions(security_type=SecurityType.DISABLED), # untagged -- raises ValueError
],
)

Feedback