Skip to main content

Let your services call with tokens

Let your own workloads call a model with short-lived tokens from your OpenID Connect issuer: a Kubernetes cluster's service-account issuer, or your identity provider. AstroPulse stores no secret: the gateway checks each token's signature, issuer, audience and claims, and you can test a token before any traffic. For callers you hand a secret to, use API keys.

Before you start​

  • A model on a verified domain, on a cluster with the Gateway add-on. See Access policies.
  • An issuer whose discovery document and key set are on the public internet over HTTPS. The model's cluster must reach the key set too.
  • An organization owner creates the policy and tests tokens.

In the console​

  1. Open Integrations > Access Policies and select Identity provider (OIDC).

  2. In New identity-provider (OIDC) policy, enter the Policy name, for example eks-workload-callers.

  3. Enter the Issuer: the token's iss, exactly. AstroPulse checks its discovery document when you save.

  4. Leave JWKS URI (optional) empty to read it from the issuer, or enter it.

  5. Enter the Audiences, comma separated, up to 8, for example qwen-chat.

  6. Optional: under Required claims, select Add a claim, then enter the claim, such as sub, and its allowed values, such as system:serviceaccount:team-a:chat-client.

  7. Select Create. If the issuer cannot be reached, the form offers Save without checking: fill in JWKS URI (optional) first, since an unchecked policy needs it.

    Open zoom view

    The New identity-provider (OIDC) policy form with an EKS issuer, the audience qwen-chat and a required sub claim

    The New identity-provider (OIDC) policy form with an EKS issuer, the audience qwen-chat and a required sub claimThe New identity-provider (OIDC) policy form with an EKS issuer, the audience qwen-chat and a required sub claim
  8. On the policy's row, select Test a token and test a real token: Test a token.

  9. Attach the policy to the model under Who may call it on the Access step of Deploy model.

You should see​

The policy's row reads Identity provider (OIDC) at revision 1. Test a token with a token from your issuer reads Accepted. Once the model is deployed, its Endpoint section reads Ready: "Callers must send a token that access policy eks-workload-callers accepts."

How token callers work​

A JWT (JSON Web Token, RFC 7519) is a signed set of statements called claims: who issued it (iss), who it is for (aud), who it is about (sub), and when it expires (exp). An OpenID Connect issuer is the service that signs those tokens and publishes the public keys that verify them. Your issuer can be an identity provider, or a Kubernetes cluster, which signs a token for each service account.

With a jwt policy, AstroPulse stores no secret. The caller gets a token from your issuer and sends it as Authorization: Bearer <token>; the gateway checks the signature and the claims.

Issuer, audience and claims: the three rules​

RulePolicy fieldPasses whenExample
Issuerspec.jwt.issuerThe token's iss equals it exactly, character for character, including any trailing slashhttps://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE
Audiencespec.jwt.audiences (1 to 8)The token's aud contains any one of them[qwen-chat]
Claimsspec.jwt.require (0 to 16 entries)Every entry holds: the token has the claim, and its value equals any one of valuesclaim: sub, values: [system:serviceaccount:team-a:chat-client]
  • The issuer says whose tokens are accepted. Anyone your issuer signs for can get a token, so the issuer alone is rarely enough.
  • The audience says which service a token was made for. Give your model its own audience, such as its application name, and never reuse one meant for another service (for example sts.amazonaws.com), so a token minted for something else is refused here and a token for this model is refused there. The IETF's JWT best practices make the same point (RFC 8725, section 3.9).
  • Required claims narrow the callers further, for example to one Kubernetes service account. Matching is exact. A dotted name reaches a nested claim (org.team reads team inside org), so a claim whose own name contains a dot cannot be named. Use claims that hold a single string, such as sub.

How a token is checked​

When a policy is saved:

  1. AstroPulse reads <issuer>/.well-known/openid-configuration (the OpenID Connect Discovery document) and requires its issuer to equal spec.jwt.issuer exactly.
  2. When jwksUri is empty, it is filled from the document's jwks_uri.
  3. The key set at jwksUri must hold at least one public signing key.

Both addresses must be HTTPS and on the public internet. When the provider cannot be reached, the save is refused with 422 IdentityProviderUnreachable; astroctl access-policy apply --allow-unverified saves it unchecked, and then jwksUri must be given.

On every call, the gateway on the model's cluster:

  1. Reads the token from Authorization: Bearer <token>. No token: 401.
  2. Checks iss against issuer, and that the token is not expired (exp) or not yet valid (nbf). A minute of clock skew is allowed both ways: a token is accepted up to a minute after its exp or before its nbf. Any failure: 401.
  3. Checks aud against audiences. A token for another audience: 403.
  4. Verifies its signature with a key from jwksUri (fetched and cached by the gateway, so the cluster must reach that address). Accepted algorithms: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512 and EdDSA. A bad signature: 401.
  5. Checks every require entry. A missing claim or a value not in the list: 403.
  6. Removes the token and passes the request to the model.

The checks run in this order, so a token with several faults is refused for the first one.

Example: a workload on EKS calls a model with its service-account token​

Every EKS cluster is an OpenID Connect issuer. A pod can ask Kubernetes for a short-lived token for its service account, with an audience you choose (service account token projection). The gateway checks that token directly, so this needs no IAM role and no IAM OIDC provider. The calling pod can run on any cluster; the model's cluster only needs to reach the issuer over the internet.

Here a pod running as service account chat-client in namespace team-a on eks-prod calls the model qwen-chat.

1. Find the cluster's issuer URL (Amazon EKS user guide):

Terminal
aws eks describe-cluster --name eks-prod \
--query "cluster.identity.oidc.issuer" --output text
https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE

2. Write the policy. A Kubernetes service-account token carries sub: system:serviceaccount:<namespace>:<service-account>, so requiring sub admits exactly one service account.

YAML
# eks-workload-callers.yaml
apiVersion: platform.astropulse.io/v1
kind: AccessPolicy
metadata:
name: eks-workload-callers
spec:
jwt:
issuer: https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE
audiences: [qwen-chat]
require:
- claim: sub
values: ["system:serviceaccount:team-a:chat-client"]
Terminal
astroctl access-policy apply -f eks-workload-callers.yaml

The answer is revision 1, with jwksUri filled in from the cluster's discovery document.

3. Create the namespace, the service account and a pod with a token for that audience. The kubelet writes the token to a file and replaces it before it expires (after 80% of its lifetime or 24 hours); the application must read the file again rather than keep the first value.

YAML
# chat-client.yaml
apiVersion: v1
kind: Namespace
metadata:
name: team-a
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: chat-client
namespace: team-a
---
apiVersion: v1
kind: Pod
metadata:
name: chat-client
namespace: team-a
spec:
serviceAccountName: chat-client
containers:
- name: app
image: python:3.12-slim
command: ["sleep", "infinity"]
volumeMounts:
- name: qwen-chat-token
mountPath: /var/run/secrets/qwen-chat
readOnly: true
volumes:
- name: qwen-chat-token
projected:
sources:
- serviceAccountToken:
audience: qwen-chat # one of the policy's audiences
expirationSeconds: 3600
path: token
Terminal
kubectl apply -f chat-client.yaml
kubectl wait --for=condition=Ready pod/chat-client -n team-a --timeout=120s

4. Test a token before any traffic. Mint one for the same service account and audience from a terminal, and test it:

Terminal
kubectl create token chat-client -n team-a --audience qwen-chat --duration 10m > t.jwt
astroctl access-policy test eks-workload-callers --token-file t.jwt
YAML
accepted: true

5. Attach the policy to the model and deploy it. In the model's Application manifest, set spec.externalAccess (excerpt):

YAML
spec:
externalAccess:
domain: apps.example.com
accessPolicyRef:
name: eks-workload-callers

Wait until astroctl app endpoint get qwen-chat shows phase: Ready.

6. Call the model from the pod. Install the openai package in the pod, then run the call there. The script reads the token file for each client, so a rotated token is picked up:

Terminal
kubectl exec -n team-a chat-client -- pip install --quiet openai
kubectl exec -i -n team-a chat-client -- python - <<'EOF'
from pathlib import Path
from openai import OpenAI

TOKEN = Path("/var/run/secrets/qwen-chat/token")

def model_client() -> OpenAI:
return OpenAI(base_url="https://qwen-chat.apps.example.com/v1",
api_key=TOKEN.read_text().strip())

reply = model_client().chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Hello"}],
)
print(reply.choices[0].message.content)
EOF

The same steps work on any Kubernetes cluster whose service-account issuer is published over HTTPS on the public internet.

Example: a service with a token from your identity provider​

Any OpenID Connect provider that signs tokens with one of the algorithms above works. A service with no Kubernetes identity, such as a backend or a CI job, typically uses the OAuth 2.0 client credentials grant (RFC 6749, section 4.4): it trades a client ID and secret, kept by your provider, for a short-lived token.

1. In your provider, register the service as a client and set the token's audience to the value your policy will name, here https://inference.example.com. Providers name this setting differently (an API, a resource, an audience); what matters is the aud in the token.

2. Write and apply the policy. Use the issuer exactly as your provider's discovery document spells it:

Terminal
curl -s https://login.example.com/.well-known/openid-configuration | jq -r .issuer
YAML
apiVersion: platform.astropulse.io/v1
kind: AccessPolicy
metadata:
name: backend-callers
spec:
jwt:
issuer: https://login.example.com/
audiences: ["https://inference.example.com"]
require:
- claim: sub
values: ["<client-id>"] # many providers put the client's ID in sub; check your token
Terminal
astroctl access-policy apply -f backend-callers.yaml

3. Get a token and read its claims. The token address is the token_endpoint of the discovery document; the audience parameter name depends on your provider.

Terminal
curl -s https://login.example.com/oauth2/token \
-d grant_type=client_credentials \
-d client_id='<client-id>' \
-d client_secret='<client-secret>' \
-d audience=https://inference.example.com | jq -r .access_token > t.jwt

# print the claims (reading only: decoding does not verify the token)
python3 -c 'import sys,json,base64; p=open(sys.argv[1]).read().split(".")[1]; print(json.dumps(json.loads(base64.urlsafe_b64decode(p+"="*(-len(p)%4))),indent=2))' t.jwt

Check that iss, aud and sub read as the policy expects, then test it (next section) and call the model with Authorization: Bearer <token> as in the EKS example.

Test a token​

In the console, select Test a token on the policy's row. In Test a token against <policy>, paste the token and select Test: it reads Accepted or Refused, with the reason. The platform checks it exactly as the gateway would, against the policy's latest revision: signature, issuer, audience, expiry and required claims. The token is never stored or logged.

Terminal
astroctl access-policy test <policy-name> --token-file <file>
# or from stdin
kubectl create token chat-client -n team-a --audience qwen-chat | astroctl access-policy test eks-workload-callers --token-file -

--token-file takes a file holding the JWT, with or without Bearer , or - for stdin; a token is never a flag value, so it stays out of shell history. The answer has accepted, reason (a code, empty when accepted) and message. A token minted with the wrong audience:

YAML
accepted: false
message: the token's audience [other-aud] includes none of the policy's [qwen-chat]
reason: AudienceMismatch

Every reason and its fix: Token refused.

Other ways in​

  • Nova: ask it, for example "why is this token refused by eks-workload-callers?".
  • SDK and API: the same operations, in the API reference.

With astroctl​

Terminal
astroctl access-policy apply -f eks-workload-callers.yaml
astroctl access-policy apply -f eks-workload-callers.yaml --allow-unverified # issuer unreachable; jwksUri required
astroctl access-policy test eks-workload-callers --token-file t.jwt

Every flag: astroctl access-policy.

Settings​

issuer, jwksUri, audiences and require are on Access policies. identity, projecting a claim into an upstream header, is refused in this release.

If it's refused​

"… Retry once the provider is live, or save with allowUnverified=true to skip the check"
Cause
422 IdentityProviderUnreachable: AstroPulse could not reach the issuer or its key set, or it is not on the public internet.
Fix
Retry once it is up, or select Save without checking with JWKS URI (optional) filled in (apply --allow-unverified).
"without the provider its key set is unknown, so give jwksUri to save unverified"
Cause
A save without checking, with no JWKS URI.
Fix
Enter JWKS URI (optional), then save again.
"this policy accepts API keys, not tokens"
Cause
A token test against an API-key policy.
Fix
Test the token against a jwt policy.

A save can also be refused on field jwt when the discovery document names another issuer, names no jwks_uri, or its key set has no public signing key; the message says which. Make Issuer match the discovery document's issuer exactly.

A caller's call refused with 401 or 403: Token refused.

Next​