Tokens & refresh
What each token is for, how long it lasts, and how to verify it.
The three tokens
| Token | Lifetime | Use |
|---|---|---|
| Authorization code | 60 seconds, single use | Exchanged once for tokens. |
| Access token | 15 minutes (900s) | Calling /oauth/userinfo. |
| Refresh token | 30 days | Getting a new access token without re-prompting. |
| ID token | — | A signed assertion of who signed in. Not a credential. |
The access token is deliberately narrow
It carries an audience of your client_id and is accepted only
at /oauth/userinfo. It is not a session token, it carries no roles, and it
grants nothing on the /api/** surface. Do not attempt to call other endpoints
with it.
Verifying the ID token
Signed with HS256 — a symmetric algorithm — using your client secret as the
key. Verify:
- Signature, with your
client_secretas the HMAC key. issequalshttps://account.gocosys.com.audequals yourclient_id.expis in the future.noncematches the one you sent, if you sent one.
JWKS will not help you. /oauth/jwks.json returns an empty key
set, because a symmetric key cannot be published without giving away the ability to forge
tokens. Libraries that insist on fetching keys from JWKS must be configured with the
client secret instead — this is the single most common integration failure here.
Worked example — Python
With PyJWT the whole verification is one call. Pin the algorithm to
HS256; never feed the token's own alg back in, and never use
options={"verify_signature": False}.
import jwt
claims = jwt.decode(
id_token,
YOUR_CLIENT_SECRET, # the HMAC key
algorithms=["HS256"], # pinned, never read from the token header
audience=YOUR_CLIENT_ID,
issuer="https://account.gocosys.com",
)
Worked example — Django
For mozilla-django-oidc, the two settings that matter are the signing
algorithm and the secret used as the HMAC key:
OIDC_RP_CLIENT_ID = "your_client_id"
OIDC_RP_CLIENT_SECRET = "your_client_secret"
OIDC_RP_SIGN_ALGO = "HS256" # must match discovery; do NOT set RS256
OIDC_OP_AUTHORIZATION_ENDPOINT = "https://account.gocosys.com/oauth-authorize"
OIDC_OP_TOKEN_ENDPOINT = "https://account.gocosys.com/oauth/token"
OIDC_OP_USER_ENDPOINT = "https://account.gocosys.com/oauth/userinfo"
OIDC_USE_PKCE = True
OIDC_PKCE_CODE_CHALLENGE_METHOD = "S256"
OIDC_RP_SCOPES = "openid profile email"
Leave OIDC_OP_JWKS_ENDPOINT unset. With
OIDC_RP_SIGN_ALGO = "HS256" the library uses
OIDC_RP_CLIENT_SECRET as the key and never needs JWKS; pointing it at our
empty key set is exactly what produces the "no keys found" failure.
Refreshing
curl -X POST https://account.gocosys.com/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=THE_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
Refresh when the access token is near expiry, or when a call returns
401. If the refresh itself fails, the grant is gone — send the user back
through authorization.
Tokens can die early
Do not assume a token survives to its stated expiry. It stops working when:
- the user revokes your application from their Connected apps page;
- an administrator revokes the client;
- you revoke it yourself.
Treat 401 as "re-authorize this user", not as a transient
error to retry. Retrying a revoked token in a loop will never succeed.
Revoking
curl -X POST https://account.gocosys.com/oauth/revoke \
-d token=ACCESS_OR_REFRESH_TOKEN \
-d token_type_hint=refresh_token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
Call this when a user signs out of your application, if you do not intend to keep acting on
their behalf. It always returns 200, even for an unknown token.
Storing tokens
- Keep refresh tokens server-side, encrypted at rest. They are long-lived credentials.
- Never put tokens in
localStoragein a browser — any XSS reads them. - Never log them. They grant access on their own.
- Scope them to the user they belong to, so one account's token can never be used for another.