Secure login
Set up a secure login for the frontend app with your OIDC provider to enforce user authentication for your portal. A secure login also enables portal self-servicing use cases, including API subscriptions, API credential management, and team setups.
Before you begin
As Portal admin, set up a portal web server.
Step 1: Set up the IdP
To authenticate portal users, you need an OpenID Connect (OIDC) identity provider (IdP). The portal frontend redirects unauthenticated users to the IdP login page and receives a JWT token after successful authentication. The portal uses claims in that token, such as email, name, groups, and sub — to identify the user and determine their access level.
This guide uses Keycloak as the IdP. You can adapt these steps for your own local or cloud IdP.
Install Keycloak
Deploy Keycloak to your cluster and expose it with a load balancer service.
Create a namespace for your Keycloak deployment.
kubectl create namespace keycloakCreate the Keycloak deployment.
kubectl -n keycloak apply -f https://raw.githubusercontent.com/solo-io/gloo-mesh-use-cases/main/policy-demo/oidc/keycloak.yamlWait for the Keycloak rollout to finish.
kubectl -n keycloak rollout status deploy/keycloakSet the Keycloak endpoint details from the load balancer service. If you are running locally in kind and need a local IP address for the load balancer service, consider using cloud-provider-kind.
export ENDPOINT_KEYCLOAK=$(kubectl -n keycloak get service keycloak -o jsonpath='{.status.loadBalancer.ingress[0].ip}{.status.loadBalancer.ingress[0].hostname}'):8080 export HOST_KEYCLOAK=$(echo ${ENDPOINT_KEYCLOAK} | cut -d: -f1) export PORT_KEYCLOAK=$(echo ${ENDPOINT_KEYCLOAK} | cut -d: -f2) export KEYCLOAK_URL=http://${ENDPOINT_KEYCLOAK} echo $KEYCLOAK_URLOpen the Keycloak URL and log in with the default admin user. To log in, use the
adminusername andadminpassword.open $KEYCLOAK_URL
Create a realm and portal client
After Keycloak is running, use the Admin REST API and default admin user to create a dedicated portal realm and configure the portal-frontend OIDC client. The portal-frontend client is used by the portal frontend to initiate the user login flow. To dynamically register OAuth clients on behalf of portal users, the service account of the OIDC client must be assigned the manage-clients role.
Get a JWT token from Keycloak for the default admin user. This token is required for all subsequent commands to create the realm, client, users, and scopes. If you see a parsing error, try running the curl command by itself without piping to
jq.export KEYCLOAK_TOKEN=$(curl -s \ -d "client_id=admin-cli" \ -d "username=admin" \ -d "password=admin" \ -d "grant_type=password" \ "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ | jq -r .access_token) echo $KEYCLOAK_TOKENCreate the
portalrealm and theportal-frontendclient. Then, create theadminandusersgroups, and assign themanage-clientsrole to theportal-frontendservice account. Theadminandusersgroups are used to distinguish portal admins from regular portal users based on thegroupsclaim in the JWT. Themanage-clientsrole on the service account allows the portal backend to programmatically create and manage OIDC clients in Keycloak.# Create the portal realm curl -s -X POST "${KEYCLOAK_URL}/admin/realms" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"realm":"portal","enabled":true,"sslRequired":"none"}' # Create the portal-frontend client curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/clients" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "clientId": "portal-frontend", "enabled": true, "clientAuthenticatorType": "client-secret", "standardFlowEnabled": true, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": true, "authorizationServicesEnabled": true, "redirectUris": ["*"], "webOrigins": ["*"], "attributes": { "post.logout.redirect.uris": "*" } }' # Get the client internal ID and secret PORTAL_CLIENT_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients?clientId=portal-frontend" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq -r '.[0].id') echo "Portal client ID: $PORTAL_CLIENT_ID" export KEYCLOAK_CLIENT=portal-frontend export KEYCLOAK_SECRET=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients/${PORTAL_CLIENT_ID}/client-secret" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq -r '.value') echo "Keycloak secret: $KEYCLOAK_SECRET" # Create admin and users groups curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/groups" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"name":"admin"}' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/groups" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"name":"users"}' # Refresh the token before role assignment export KEYCLOAK_TOKEN=$(curl -s \ -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \ -d "grant_type=password" \ "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ | jq -r .access_token) # Get the realm-management client ID REALM_MGMT_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients?clientId=realm-management" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq -r '.[0].id') # Get the manage-clients role MANAGE_CLIENTS_ROLE=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients/${REALM_MGMT_ID}/roles/manage-clients" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}") # Get the service account user ID for portal-frontend SA_USER_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/clients/${PORTAL_CLIENT_ID}/service-account-user" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq -r '.id') # Assign the manage-clients role to the service account curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/users/${SA_USER_ID}/role-mappings/clients/${REALM_MGMT_ID}" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d "[${MANAGE_CLIENTS_ROLE}]"Verify that the
manage-clientsrole is correctly assigned to theportal-frontendservice account.curl -s "${KEYCLOAK_URL}/admin/realms/portal/users/${SA_USER_ID}/role-mappings/clients/${REALM_MGMT_ID}" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq '.[].name'Example output:
"manage-clients"
Create JWT claims and test users
The portal frontend reads specific claims from the JWT token to identify users and determine their role in the portal. The following claims must be present in the token:
emailnamepreferred_usernamesubgroups
The email, email_verified, preferred_username, sub, name, given_name, and family_name claims are automatically included in Keycloak access tokens when the profile and email scopes are requested. However, the groups claim is not included by default and must be configured explicitly. Use the following steps to add a group membership protocol mapper to the portal-frontend client, then create test users in both the admin and users groups so you can verify the login flow end to end.
Get a fresh admin token and add the
groupsprotocol mapper to theportal-frontendclient. The mapper instructs Keycloak to include agroupsclaim in the access token, ID token, and userinfo response. The claim is an array that includes the names of all groups that the authenticated user belongs to.export KEYCLOAK_TOKEN=$(curl -s \ -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \ -d "grant_type=password" \ "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ | jq -r .access_token) # Add the groups claim (maps Keycloak group membership to a groups array in the token) curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/clients/${PORTAL_CLIENT_ID}/protocol-mappers/models" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "config": { "claim.name": "groups", "full.path": "false", "access.token.claim": "true", "id.token.claim": "true", "userinfo.token.claim": "true" } }'Create the
user1test user and assign them to theusersgroup. This user represents a regular portal user who can view APIs, create teams, apps, and subscriptions, and create API credentials, but cannot perform admin-level actions like approving subscriptions or setting up API rate limits.curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/users" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"username":"user1","firstName":"Alice","lastName":"Doe","email":"user1@example.com","enabled":true,"emailVerified":true}' USER1_ID=$(curl -s -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/portal/users" \ | python3 -c "import sys,json; print([u['id'] for u in json.load(sys.stdin) if u['username']=='user1'][0])") echo "User ID: $USER1_ID" curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${USER1_ID}/reset-password" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"type":"password","value":"password","temporary":false}' USERS_GROUP_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/groups" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq -r '.[] | select(.name=="users") | .id') curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${USER1_ID}/groups/${USERS_GROUP_ID}" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}"Request a token for
user1and decode the JWT payload to confirm that all required claims are present. Look foremail,name,preferred_username,sub,email_verified, and agroupsarray containing"users".curl -s -X POST "${KEYCLOAK_URL}/realms/portal/protocol/openid-connect/token" \ -d "client_id=${KEYCLOAK_CLIENT}" \ -d "client_secret=${KEYCLOAK_SECRET}" \ -d "grant_type=password" \ -d "username=user1" \ -d "password=password" \ -d "scope=openid email profile" \ | python3 -c " import sys, json, base64 d = json.load(sys.stdin) if 'error' in d: print(json.dumps(d, indent=2)) sys.exit(1) payload = d['access_token'].split('.')[1] payload += '=' * (4 - len(payload) % 4) print(json.dumps(json.loads(base64.b64decode(payload)), indent=2)) "Example output:
{ "exp": 1776095780, "iat": 1776095480, "auth_time": 1776095479, "jti": "79274118-2b5f-4336-8440-107c0c2f2687", "iss": "http://172.18.0.30:8080/realms/portal", "aud": "account", "sub": "024fba27-1e38-4aa5-a247-6a5ea8794c75", "typ": "Bearer", "azp": "portal-frontend", "sid": "d9200443-15e2-491d-bb6c-762adbc36a2f", "acr": "1", "allowed-origins": ["*"], "realm_access": { "roles": [ "offline_access", "uma_authorization", "default-roles-portal" ] }, "resource_access": { "account": { "roles": [ "manage-account", "manage-account-links", "view-profile" ] } }, "scope": "openid email profile", "email_verified": true, "name": "Alice Doe", "groups": ["users"], "preferred_username": "user1", "given_name": "Alice", "family_name": "Doe", "email": "user1@example.com" }Create the
admin1user and assign them to theadmingroup. This user represents a portal admin who has elevated privileges in the portal, including approving subscription requests, and setting up credential management and rate limits for APIs.export KEYCLOAK_TOKEN=$(curl -s \ -d "client_id=admin-cli" -d "username=admin" -d "password=admin" \ -d "grant_type=password" \ "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ | jq -r .access_token) curl -s -X POST "${KEYCLOAK_URL}/admin/realms/portal/users" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"username":"admin1","firstName":"Bob","lastName":"Admin","email":"admin1@solo.io","enabled":true,"emailVerified":true}' ADMIN1_ID=$(curl -s -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/portal/users" \ | python3 -c "import sys,json; print([u['id'] for u in json.load(sys.stdin) if u['username']=='admin1'][0])") echo "Admin user ID: $ADMIN1_ID" curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${ADMIN1_ID}/reset-password" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"type":"password","value":"password","temporary":false}' ADMIN_GROUP_ID=$(curl -s "${KEYCLOAK_URL}/admin/realms/portal/groups" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ | jq -r '.[] | select(.name=="admin") | .id') curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/portal/users/${ADMIN1_ID}/groups/${ADMIN_GROUP_ID}" \ -H "Authorization: Bearer ${KEYCLOAK_TOKEN}"Request a token for
admin1and decode the JWT payload to confirm that all required claims are present. Verify that thegroupsarray contains"admin", which is what the portal uses to grant admin-level access.curl -s -X POST "${KEYCLOAK_URL}/realms/portal/protocol/openid-connect/token" \ -d "client_id=${KEYCLOAK_CLIENT}" \ -d "client_secret=${KEYCLOAK_SECRET}" \ -d "grant_type=password" \ -d "username=admin1" \ -d "password=password" \ -d "scope=openid email profile" \ | python3 -c " import sys, json, base64 d = json.load(sys.stdin) if 'error' in d: print(json.dumps(d, indent=2)) sys.exit(1) payload = d['access_token'].split('.')[1] payload += '=' * (4 - len(payload) % 4) print(json.dumps(json.loads(base64.b64decode(payload)), indent=2)) "Example output:
{ "exp": 1776095747, "iat": 1776095447, "auth_time": 1776095446, "jti": "b5efebb7-5075-4dfb-9784-c20f992bb6fb", "iss": "http://172.18.0.30:8080/realms/portal", "aud": "account", "sub": "aa67ed19-3f64-47af-a58a-bc2a69d56b99", "typ": "Bearer", "azp": "portal-frontend", "sid": "31eec05e-76d4-4b87-8e6a-0b3d1ce8d700", "acr": "1", "allowed-origins": ["*"], "realm_access": { "roles": [ "offline_access", "uma_authorization", "default-roles-portal" ] }, "resource_access": { "account": { "roles": [ "manage-account", "manage-account-links", "view-profile" ] } }, "scope": "openid email profile", "email_verified": true, "name": "Bob Admin", "groups": ["admin"], "preferred_username": "admin1", "given_name": "Bob", "family_name": "Admin", "email": "admin1@solo.io" }
Step 2: Secure the portal frontend
With your IdP configured, you can now wire up the portal frontend and gateway to enforce user authentication. When a user logs in to the portal with a JWT that has a valid groups claim, the portal frontend shows self-service features, such as team, app, and subscription management. Note that these features are hidden without a secure login.
Update the portal frontend app to include the OIDC callback and logout paths.
The
VITE_APPLIED_OIDC_AUTH_CODE_CONFIGvariable enables the OIDC authorization code flow in the frontend.VITE_OIDC_AUTH_CODE_CONFIG_CALLBACK_PATHandVITE_OIDC_AUTH_CODE_CONFIG_LOGOUT_PATHtell the frontend which paths to use for handling the OIDC redirect after login and the logout redirect, respectively. The paths must match thecallbackPathandlogoutPathvalues that you configure in theAuthConfigin a later step.kubectl apply -f- <<EOF apiVersion: v1 kind: ServiceAccount metadata: name: portal-ui namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: portal-ui namespace: default spec: replicas: 1 selector: matchLabels: app: portal-ui template: metadata: labels: app: portal-ui spec: serviceAccountName: portal-ui securityContext: runAsNonRoot: true runAsUser: 10101 containers: - name: portal-ui image: gcr.io/solo-public/docs/portal-frontend:v0.1.8 imagePullPolicy: IfNotPresent ports: - name: http containerPort: 4000 protocol: TCP env: - name: VITE_PORTAL_SERVER_URL value: "http://portal.example.com:8080/v1" - name: VITE_APPLIED_OIDC_AUTH_CODE_CONFIG value: "true" - name: VITE_OIDC_AUTH_CODE_CONFIG_CALLBACK_PATH value: "/v1/login" - name: VITE_OIDC_AUTH_CODE_CONFIG_LOGOUT_PATH value: "/v1/logout" livenessProbe: httpGet: path: / port: http initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: httpGet: path: / port: http initialDelaySeconds: 5 periodSeconds: 5 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL --- apiVersion: v1 kind: Service metadata: name: portal-ui namespace: default spec: type: ClusterIP ports: - port: 4000 targetPort: http protocol: TCP name: http selector: app: portal-ui EOFCreate a Kubernetes secret that holds the
client-idandclient-secretfor theportal-frontendOIDC client. TheAuthConfigyou create in the next step references this secret to authenticate with the IdP when exchanging the authorization code for tokens.kubectl apply -f - <<EOF apiVersion: v1 kind: Secret metadata: name: portal-oidc namespace: default type: extauth.solo.io/oauth stringData: client-id: $KEYCLOAK_CLIENT client-secret: $KEYCLOAK_SECRET EOFCreate the
AuthConfig. This resource configures the OIDC authorization code flow at the gateway level. It specifies the IdP issuer URL, the client credentials to use, the callback and logout paths, and the session cookie settings. TheaccessTokenHeader: id_tokenfield forwards the ID token as a request header to the portal backend so it can extract user identity from it.kubectl apply -f - <<EOF apiVersion: extauth.solo.io/v1 kind: AuthConfig metadata: name: portal-oidc namespace: default spec: configs: - oauth2: oidcAuthorizationCode: appUrl: "http://portal.example.com:8080" callbackPath: /v1/login logoutPath: /v1/logout clientId: $KEYCLOAK_CLIENT clientSecretRef: name: portal-oidc namespace: default issuerUrl: "$KEYCLOAK_URL/realms/portal" session: cookieOptions: notSecure: true cookie: allowRefreshing: true scopes: - email - profile headers: accessTokenHeader: id_token EOFCreate a dedicated HTTPRoute for the OIDC login and logout paths, and an EnterpriseKgatewayTrafficPolicy that targets it. All other
/v1/traffic is handled by themy-portal-backendHTTPRoute without authentication. Because the/v1/loginand/v1/logoutpaths are more specific than the/v1/prefix inmy-portal-backend, the gateway routes OIDC callback and logout requests through the dedicated route first.kubectl apply -f - <<EOF apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: portal-login namespace: default spec: parentRefs: - name: portal-gateway namespace: default hostnames: - "portal.example.com" rules: - matches: - path: type: PathPrefix value: /v1/login - path: type: PathPrefix value: /v1/logout backendRefs: - name: portal-my-portal namespace: default port: 8080 --- apiVersion: enterprisekgateway.solo.io/v1alpha1 kind: EnterpriseKgatewayTrafficPolicy metadata: name: trafficpolicy-portal-oidc namespace: default spec: targetRefs: - group: gateway.networking.k8s.io kind: HTTPRoute name: portal-login entExtAuth: authConfigRef: name: portal-oidc namespace: default EOFOpen the portal in your browser to verify the frontend is reachable.
Click Login. You are redirected to the Keycloak login page. Log in with one of the following test user credentials to verify that both user roles work correctly:
- Portal user: username:
user1, password:password - Portal admin: username:
admin1, password:password
Verify that you are redirected back to the portal frontend app and that you see the user login. The portal frontend also shows protected features, such as Teams, Apps, or Subscriptions. The features that you see depend on the type of user that is logged into the frontend.

- Portal user: username:
Next
Explore the following admin tasks.